From 7225c806fab5c6be2657b69312345b185385484d Mon Sep 17 00:00:00 2001 From: Jaeho Shin Date: Tue, 10 Nov 2026 21:48:10 -0800 Subject: [PATCH 1/7] eXtract: C-e edits, C-o opens, C-r relative/absolute paths Co-Authored-By: Claude Sonnet 5.5 --- docs/usage-extract.md | 5 +- optmux/data/bin/optmux-extract | 186 +++++++++++++++++++++++++++++++-- tests/test_extract.py | 93 +++++++++++++++++ 3 files changed, 276 insertions(+), 8 deletions(-) diff --git a/docs/usage-extract.md b/docs/usage-extract.md index d01c7b0..613c4cb 100644 --- a/docs/usage-extract.md +++ b/docs/usage-extract.md @@ -4,11 +4,14 @@ - **`C-M-x`** again (the key that opened the picker) switches between **lines** and **spans**. Spans are the runs of text the program colored or styled (the command, path or name worth copying; one the program wrapped onto the next line in the same style is joined back when it clearly filled the line), mixed in screen order with the patterns it did not style (URLs, paths, file names with an optional `:line:col`, commit hashes, PR or issue numbers like `#234`, quoted text (`'x'`, `"x"`, `` `x` ``, GNU `` `x' ``), semver versions). The last one used is remembered. - **`C-y`** copies by the method **`C-t`** cycles: `osc52` (eXtract writes an OSC 52 escape to its own terminal and tmux relays it, so it reaches your local clipboard over SSH too), `native` (`pbcopy`, `wl-copy`, `xclip` or `xsel`) or `buffer` (the tmux buffer only). Also remembered. Either way the tmux buffer holds the text afterwards. +- **`C-e`** edits the picks in `$EDITOR` (`vi` if unset) in a new pane beside the one you came from, in its current directory. File names and paths that exist open as files, at their `:line:col` when they have one (`+LINE` for vim, nvim, nano and emacs, `file:line:col` for Helix, `-g` for VS Code), several picks in one editor; relative ones are taken from that directory, and URLs are left to `C-o`. Anything else (a command, quoted text, lines, a hash) is written to a temporary file that opens instead, and is deleted when you quit the editor. +- **`C-o`** opens each pick by its kind: on macOS a file is revealed in Finder, a directory opened and a URL sent to the browser (`xdg-open` elsewhere); a commit hash or `#234` opens on GitHub with `gh browse`, when `gh` is installed and the pane is in a git repository. What can't be opened (or a path that doesn't exist) is left alone, with a short message saying why. +- **`C-r`** cycles how paths (that exist) are typed and copied: **shown** as on the screen, **relative** to the project root (the pane's git repository, else its directory) or **absolute**; a `:line:col` stays. Remembered like the copy method (`@optmux-extract-paths`). - **Where you were is remembered**, per filter: eXtract opens on the query you left (leaving with `Esc` counts, and an emptied query stays empty), with the cursor on the item you last picked, and `M-p` steps back through that filter's earlier queries before it, each with the cursor on the item you picked then, so the `bash`, `.tmp/` or pattern you use often is a few keystrokes away (`M-n` goes forward again to a fresh, empty start). A query that `M-p` recalls and that matches nothing on the screen now is skipped; the one eXtract opens on stays even then (`C-u` clears it). Spans in plain grey (a coding tool's status lines and hints) are left out, so they don't crowd the list; bold or highlighted ones stay. To find spans starting with a bang, type `^!` or `'!`: a lone `!` is fzf's negation, which matches everything. `C-u` clears the query. The **spans** filter draws each pattern kind with an icon (🔗 URL, 📁 path, 📄 file name, 💬 quoted text, #️⃣ commit hash, 🔀 PR or issue number, 🏷️ version) and the program's own styled runs with 🎨, in the style of `+icons` (`nerd` by default; shown here as emoji) like Find. - **`C-f`, `C-g`, `C-M-f`** look the selection up in the project instead of typing it: the picks (or, with nothing matching, the text you typed in the filter) become the query of [OPTMUX Find](usage-find.md), which continues in this same popup: **`C-f`** in the files, **`C-g`** by grep, **`C-M-f`** in omni (tags, symbols and files). Pick an error's path, a function name or a phrase from the screen, then jump to where it lives (a `:line:col` after a path is left off, since a file name has none). - Several picks go on one line separated by spaces, except lines mode (always one pick per line) and picks that themselves contain whitespace, such as commands in spans mode, which each get their own line. - Picks are pasted as a bracketed paste, so a shell (or Claude Code) takes several lines as one paste. A program that doesn't ask for bracketed paste (plain `cat`, some REPLs) receives each newline as `Enter`. -- Both are kept as tmux options on the project's server (`@optmux-extract-filter`, `@optmux-extract-copy`), so they last until it stops. +- These are kept as tmux options on the project's server (`@optmux-extract-filter`, `@optmux-extract-copy`, `@optmux-extract-paths`), so they last until it stops. ## Clipboard and OSC 52 diff --git a/optmux/data/bin/optmux-extract b/optmux/data/bin/optmux-extract index e13a930..11a03e7 100755 --- a/optmux/data/bin/optmux-extract +++ b/optmux/data/bin/optmux-extract @@ -13,6 +13,10 @@ item the cursor was on: it opens on the query you left, and `M-p` steps back thr cursor), `M-n` forward again to a fresh start (`C-u` clears the query). `icons` (emoji, nerd or text, as in optmux Find) says how a pattern's kind is drawn. +`C-e` edits the selection in `$EDITOR` in a new pane (files at their `:line:col`, anything else through a temp file), `C-o` +opens it (files revealed in Finder, URLs in the browser, `#234` and commit hashes on GitHub), and `C-r` cycles how +paths are typed and copied: as shown, relative to the project, or absolute. + The selection can also be looked up in the project instead: `C-f` finds it among the files, `C-g` greps for it and `C-M-f` searches everything (optmux Find, started in this same popup with the text as its query). """ @@ -132,6 +136,8 @@ FILTERS = ["lines", "spans"] COPIERS = ["osc52", "native", "buffer"] FILTER_OPTION = "@optmux-extract-filter" COPY_OPTION = "@optmux-extract-copy" +PATH_STYLES = ["shown", "relative", "absolute"] +PATH_OPTION = "@optmux-extract-paths" # Well-known things worth copying. An earlier pattern claims its text first, so a path inside a URL # is not listed again, and nothing overlaps within a line. @@ -423,14 +429,15 @@ FIND_KEYS = {"ctrl-f": "files", "ctrl-g": "grep", "ctrl-alt-f": "omni"} MAX_QUERY = 200 # characters of selection handed to Find -def labels(filter_name, copier): +def labels(filter_name, copier, paths="shown"): """fzf's --header (two lines: editing keys, then the lookup keys) and --prompt: what is listed and which keys do what.""" if shutil.get_terminal_size().columns < NARROW: - return [f"--header=Up/Down; Tab: multi; Enter: insert; C-y: copy ({copier}; C-t)\nC-u: clear; M-p: older; C-f: files; C-g: grep; C-M-f: all", + return [f"--header=Tab: multi; Enter: type; C-y: copy ({copier}; C-t); C-e: edit; C-o: open\n" + f"C-r: paths {paths}; C-u: clear; M-p: older; C-f: files; C-g: grep; C-M-f: all", f"--prompt={filter_name} (C-M-x) > "] - return [f"--header=Up/Down to select; Tab to multiselect; Enter to insert; C-y to copy ({copier}; C-t to switch)\n" - "M-p/M-n: older/newer query; C-u: clear; in the project: C-f files; C-g grep; C-M-f all", + return [f"--header=Up/Down to select; Tab to multiselect; Enter to insert; C-y to copy ({copier}; C-t to switch); C-e edit; C-o open\n" + f"C-r: paths {paths}; M-p/M-n: older/newer query; C-u: clear; in the project: C-f files; C-g grep; C-M-f all", f"--prompt={filter_name} filtered (C-M-x to switch) > "] @@ -481,6 +488,161 @@ def open_find(pane, source, query): return 1 +def classify(pick): + """What a pick is as a whole: url, path, file, hash, pr, or other (anything that is not exactly one pattern).""" + pick = pick.strip() + found = find_patterns(pick) + if len(found) == 1 and found[0][0] == 0 and found[0][2] == pick and found[0][1] in ("url", "path", "file", "hash", "pr"): + return found[0][1] + return "other" + + +POSITION = re.compile(r"^(.*?)(?::(\d+))?(?::(\d+))?$") + + +def split_position(pick): + """(path, line, col) for `path[:line[:col]]`; line and col are None when absent.""" + path, line, col = POSITION.match(pick.strip()).groups() + return path, line and int(line), col and int(col) + + +def resolve(path, cwd): + return os.path.normpath(os.path.join(cwd, os.path.expanduser(path))) + + +def project_root(cwd): + """The repository's top level, else `cwd` itself.""" + out = subprocess.run(["git", "-C", cwd, "rev-parse", "--show-toplevel"], capture_output=True, text=True).stdout.strip() + return out if out and os.path.isdir(out) else cwd + + +def restyle(picks, style, cwd): + """The picks with the paths among them (that exist) written relative to the project root or absolute; `shown` leaves them.""" + if style == "shown" or not any(classify(p) in ("path", "file") for p in picks): + return picks + root = project_root(cwd) + out = [] + for pick in picks: + path, line, col = split_position(pick) if classify(pick) in ("path", "file") else (None, None, None) + full = resolve(path, cwd) if path else None + if not full or not os.path.exists(full): + out.append(pick) + continue + text = full if style == "absolute" else os.path.relpath(full, root) + out.append(text + "".join(f":{n}" for n in (line, col) if n is not None)) + return out + + +def editor_argv(editor, targets): + """argv to edit [(path, line, col)] with the editor command `editor`: Helix and VS Code take `path:line:col`, + anything else (vim, nano, emacs) `+LINE` ahead of the files.""" + argv = shlex.split(editor) if editor and editor.strip() else ["vi"] + name = os.path.basename(argv[0]) + where = lambda p, l, c: p + "".join(f":{n}" for n in (l, c) if n is not None) if l else p + if name in ("hx", "helix"): + return argv + [where(*t) for t in targets] + if name in ("code", "codium", "cursor"): + return argv + [a for t in targets for a in (("-g", where(*t)) if t[1] else (t[0],))] + return argv + ([f"+{targets[0][1]}"] if targets and targets[0][1] else []) + [t[0] for t in targets] + + +def notify(message): + tmux("display-message", "-d", "2500", ("optmux-extract: " + message).replace("#", "##")) + + +def pane_directory(pane): + path = tmux("display-message", "-p", "-t", pane, "#{pane_current_path}", capture_output=True, text=True).stdout.strip() + return path if os.path.isdir(path) else os.getcwd() + + +def edit_plan(chosen, mode, cwd): + """(targets, text, message): what to edit. Files that exist (with their position) when every pick is a path or + URL; else the whole selection as text, for a temp file.""" + kinds = [classify(p) for p in chosen] + if any(k not in ("path", "file", "url") for k in kinds): + return [], compose(chosen, mode), None + targets, missing = [], [] + for pick, kind in zip(chosen, kinds): + if kind == "url": + continue + path, line, col = split_position(pick) + full = resolve(path, cwd) + if os.path.exists(full): + targets.append((full, line, col)) + else: + missing.append(path) + if targets: + return targets, None, None + return [], None, f"no such file: {missing[0]}" if missing else "URLs are opened with C-o, not edited" + + +def edit(pane, chosen, mode): + cwd = pane_directory(pane) + targets, text, message = edit_plan(chosen, mode, cwd) + if message: + notify(message) + return 1 + temp = None + if text is not None: + if not text.strip(): + return 0 + with tempfile.NamedTemporaryFile("w", prefix="optmux-extract-", suffix=".txt", delete=False) as f: # (private: mode 600) + f.write(text + "\n") + temp = f.name + targets = [(temp, None, None)] + command = shlex.join(editor_argv(os.environ.get("EDITOR"), targets)) + (f"; rm -f {shlex.quote(temp)}" if temp else "") + tmux("split-window", "-t", pane, "-c", cwd.replace("#", "##"), command) + return 0 + + +def open_command(pick, cwd, system=None): + """(argv, message) for opening a pick: a URL in the browser, a path revealed in the file manager, a hash or PR/issue + number on GitHub. argv is None when it cannot be opened, and the message says why.""" + system, pick = system or sys.platform, pick.strip() + kind = classify(pick) + opener = ["open"] if system == "darwin" else ["xdg-open"] + if kind == "url": + return opener + [pick], None + if kind in ("path", "file"): + full = resolve(split_position(pick)[0], cwd) + if not os.path.exists(full): + return None, f"no such path: {pick}" + return opener + ([] if os.path.isdir(full) or system != "darwin" else ["-R"]) + [full], None + if kind in ("hash", "pr"): + if not shutil.which("gh"): + return None, f"{pick}: needs the gh command to open on GitHub" + if subprocess.run(["git", "-C", cwd, "rev-parse", "--git-dir"], capture_output=True).returncode != 0: + return None, f"{pick}: not in a git repository" + return ["gh", "browse", pick.lstrip("#")], None + return None, f"can't open: {pick[:40]}" + + +def run_open(argv, cwd): + """Start the command; the error to show when it fails at once, else None (a long-lived opener is left running).""" + try: + proc = subprocess.Popen(argv, cwd=cwd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, start_new_session=True, text=True) + try: + code = proc.wait(5) + except subprocess.TimeoutExpired: + return None + return None if code == 0 else f"{argv[0]} failed: {proc.stderr.read().strip()[:80]}" + except OSError as e: + return f"{argv[0]}: {e}" + + +def open_items(pane, chosen): + cwd = pane_directory(pane) + problems = [] + for pick in chosen: + argv, message = open_command(pick, cwd) + message = message or run_open(argv, cwd) + if message: + problems.append(message) + if problems: + notify("; ".join(problems)) + return 0 + + def parse_fzf_output(stdout): """(query, key, texts) from `fzf --print-query --expect=...`; texts in screen order, not pick order.""" query, key, *picked = stdout.split("\n")[:-1] or ["", "esc"] @@ -496,6 +658,7 @@ def pick(pane): width = int(tmux("display-message", "-p", "-t", pane, "#{pane_width}", capture_output=True, text=True).stdout) mode = get_option(FILTER_OPTION, FILTERS, FILTERS[0]) copier = get_option(COPY_OPTION, COPIERS, COPIERS[0]) + paths = get_option(PATH_OPTION, PATH_STYLES, PATH_STYLES[0]) # opens on the filter's last query and item (M-p steps `back` through the earlier ones, M-n forward to a fresh start) earlier = load_history(mode) query, remembered = earlier[0] if earlier else ("", None) @@ -522,8 +685,8 @@ def pick(pane): "fzf", "--ansi", "--multi", "--tiebreak=index", *(["--no-sort"] if shown == "lines" else []), "--no-info", "--delimiter=\t", "--with-nth=3", "--print-query", f"--query={query}", *([f"--bind=load:pos({pos})+unbind(load)"] if pos else []), - f"--expect=ctrl-alt-x,ctrl-t,ctrl-y,ctrl-c,esc,alt-p,alt-n,{','.join(FIND_KEYS)}", - *labels(shown, copier), + f"--expect=ctrl-alt-x,ctrl-t,ctrl-y,ctrl-e,ctrl-o,ctrl-r,ctrl-c,esc,alt-p,alt-n,{','.join(FIND_KEYS)}", + *labels(shown, copier, paths), ], input="\n".join(f"{i}\t{t}\t{d}" for i, (t, d) in numbered), stdout=subprocess.PIPE, text=True, ) @@ -541,7 +704,7 @@ def pick(pane): recalled = True continue # however you leave (Esc too), the filter is left as it is, an emptied query included: `C-u`, then Esc, stays cleared - if key != "ctrl-t": # (switching the copy method stays in the same session, and would shift the M-p index) + if key not in ("ctrl-t", "ctrl-r"): # (switching the copy method stays in the same session, and would shift the M-p index) save_state(shown, query, chosen[-1] if chosen else remembered) # (the newest pick, or where it was) if key in FIND_KEYS: text = find_query(chosen, query) @@ -554,9 +717,18 @@ def pick(pane): elif key == "ctrl-t": copier = cycle(COPIERS, copier) tmux("set-option", "-g", COPY_OPTION, copier) + elif key == "ctrl-r": + paths = cycle(PATH_STYLES, paths) + tmux("set-option", "-g", PATH_OPTION, paths) elif key in ("ctrl-c", "esc") or not chosen: return 0 + elif key == "ctrl-e": + return edit(pane, chosen, shown) + elif key == "ctrl-o": + return open_items(pane, chosen) else: + if paths != "shown": + chosen = restyle(chosen, paths, pane_directory(pane)) text = compose(chosen, shown) if not text.strip(): # only blank lines picked: there is nothing to type or copy return 0 diff --git a/tests/test_extract.py b/tests/test_extract.py index 6cb835c..029e357 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -425,3 +425,96 @@ def test_white_is_text_not_grey_and_the_mode_line_hint_is_covered(): defaults = yaml.safe_load((Path(__file__).parent.parent / "optmux/data/optmux-defaults.yaml").read_text()) patterns = defaults["+optmux"]["+find"]["+ignore_spans"]["claude_code"] assert any(re.fullmatch(p, "⏵⏵ accept edits on (shift+tab to cycle)", re.I) for p in patterns) + + +def test_classify_whole_picks(): + assert [pt.classify(p) for p in ["https://x.org/a", "src/a.py:3:1", "foo.py", "eb0eb3f", "#234", "git commit", "v1.2.3", " ./a/b "]] == [ + "url", "path", "file", "hash", "pr", "other", "other", "path"] + + +def test_split_position(): + assert pt.split_position("a/b.py:12:3") == ("a/b.py", 12, 3) + assert pt.split_position("a/b.py:12") == ("a/b.py", 12, None) + assert pt.split_position("a/b.py") == ("a/b.py", None, None) + + +def test_editor_argv_picks_the_jump_syntax_per_editor(): + t = [("/a/x.py", 12, 3), ("/a/y.py", None, None)] + assert pt.editor_argv("nvim", t) == ["nvim", "+12", "/a/x.py", "/a/y.py"] + assert pt.editor_argv("hx", t) == ["hx", "/a/x.py:12:3", "/a/y.py"] + assert pt.editor_argv("code -w", t) == ["code", "-w", "-g", "/a/x.py:12:3", "/a/y.py"] + assert pt.editor_argv(None, [("/t", None, None)]) == ["vi", "/t"] + assert pt.editor_argv(" ", [("/t", None, None)]) == ["vi", "/t"] + + +def test_edit_plan_files_that_exist_else_the_text(tmp_path): + (tmp_path / "a.py").write_text("x") + assert pt.edit_plan(["a.py:4", "https://x.org", "nope.py"], "spans", str(tmp_path)) == ([(str(tmp_path / "a.py"), 4, None)], None, None) + assert pt.edit_plan(["nope.py"], "spans", str(tmp_path))[2] == "no such file: nope.py" + assert "C-o" in pt.edit_plan(["https://x.org"], "spans", str(tmp_path))[2] + assert pt.edit_plan(["a.py", "git commit"], "spans", str(tmp_path)) == ([], "a.py\ngit commit", None) + assert pt.edit_plan([" #234"], "lines", str(tmp_path)) == ([], "#234", None) + + +def test_edit_opens_a_split_in_the_pane_directory_and_removes_its_temp_file(monkeypatch, tmp_path): + calls = [] + monkeypatch.setattr(pt, "tmux", lambda *a, **k: calls.append(a) or type("R", (), {"stdout": f"{tmp_path}\n"})()) + monkeypatch.setenv("EDITOR", "vim") + (tmp_path / "a.py").write_text("x") + assert pt.edit("%1", ["a.py:7"], "spans") == 0 + assert calls[-1][:5] == ("split-window", "-t", "%1", "-c", str(tmp_path)) + assert calls[-1][5] == f"vim +7 {tmp_path}/a.py" + assert pt.edit("%1", ["echo $(rm -rf x)"], "spans") == 0 + import shlex + words = shlex.split(calls[-1][5].split("; rm -f ")[0]) + assert words[0] == "vim" and Path(words[1]).read_text() == "echo $(rm -rf x)\n" and calls[-1][5].endswith(shlex.quote(words[1])) + Path(words[1]).unlink() + + +def test_open_command_by_kind(monkeypatch, tmp_path): + (tmp_path / "d").mkdir() + (tmp_path / "f.txt").write_text("x") + cwd = str(tmp_path) + assert pt.open_command("https://x.org/a", cwd, "darwin") == (["open", "https://x.org/a"], None) + assert pt.open_command("https://x.org/a", cwd, "linux") == (["xdg-open", "https://x.org/a"], None) + assert pt.open_command("f.txt:3", cwd, "darwin")[0] == ["open", "-R", str(tmp_path / "f.txt")] + assert pt.open_command("./d", cwd, "darwin")[0] == ["open", str(tmp_path / "d")] + assert pt.open_command("f.txt", cwd, "linux")[0] == ["xdg-open", str(tmp_path / "f.txt")] + assert pt.open_command("gone.txt", cwd, "darwin") == (None, "no such path: gone.txt") + assert pt.open_command("git commit", cwd, "darwin")[0] is None + monkeypatch.setattr(pt.shutil, "which", lambda name: None) + assert pt.open_command("#12", cwd, "darwin")[0] is None + monkeypatch.setattr(pt.shutil, "which", lambda name: "/bin/gh") + assert "not in a git repository" in pt.open_command("#12", cwd, "darwin")[1] + subprocess.run(["git", "init", "-q", cwd], check=True) + assert pt.open_command("#12", cwd, "darwin") == (["gh", "browse", "12"], None) + assert pt.open_command("eb0eb3f", cwd, "darwin") == (["gh", "browse", "eb0eb3f"], None) + + +def test_open_items_opens_each_by_its_kind_and_says_why_not(monkeypatch, tmp_path): + opened, shown = [], [] + monkeypatch.setattr(pt, "pane_directory", lambda pane: str(tmp_path)) + monkeypatch.setattr(pt, "run_open", lambda argv, cwd: opened.append(argv)) + monkeypatch.setattr(pt, "tmux", lambda *a, **k: shown.append(a)) + pt.open_items("%1", ["https://x.org", "make test", "nope.py"]) + assert len(opened) == 1 and opened[0][-1] == "https://x.org" + assert "can't open: make test" in shown[0][-1] and "no such path: nope.py" in shown[0][-1] + + +def test_restyle_paths_relative_to_the_project_or_absolute(tmp_path): + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / "src").mkdir() + (tmp_path / "src" / "a.py").write_text("x") + cwd = str(tmp_path / "src") + picks = ["a.py:3", "gone.py", "echo hi"] + assert pt.restyle(picks, "shown", cwd) == picks + assert pt.restyle(picks, "relative", cwd) == ["src/a.py:3", "gone.py", "echo hi"] + assert pt.restyle(picks, "absolute", cwd)[0] == f"{(tmp_path / 'src' / 'a.py').resolve() if False else tmp_path / 'src' / 'a.py'}:3" + + +def test_labels_mention_edit_open_and_path_style(monkeypatch): + import os + for columns in (120, 60): + monkeypatch.setattr(pt.shutil, "get_terminal_size", lambda columns=columns: os.terminal_size((columns, 30))) + header = pt.labels("spans", "osc52", "relative")[0] + assert "C-e" in header and "C-o" in header and "paths relative" in header From 638aa1aee4f00cfc9acab79aa8b5caf1707bddb4 Mon Sep 17 00:00:00 2001 From: Jaeho Shin Date: Tue, 10 Nov 2026 21:49:51 -0800 Subject: [PATCH 2/7] eXtract: relative paths through symlinks, bad $EDITOR quoting falls back to vi Co-Authored-By: Claude Sonnet 5.5 --- optmux/data/bin/optmux-extract | 10 +++++++--- tests/test_extract.py | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/optmux/data/bin/optmux-extract b/optmux/data/bin/optmux-extract index 11a03e7..5d793cc 100755 --- a/optmux/data/bin/optmux-extract +++ b/optmux/data/bin/optmux-extract @@ -520,7 +520,7 @@ def restyle(picks, style, cwd): """The picks with the paths among them (that exist) written relative to the project root or absolute; `shown` leaves them.""" if style == "shown" or not any(classify(p) in ("path", "file") for p in picks): return picks - root = project_root(cwd) + root = os.path.realpath(project_root(cwd)) # (git's top level is symlink-free, so is the path compared to it) out = [] for pick in picks: path, line, col = split_position(pick) if classify(pick) in ("path", "file") else (None, None, None) @@ -528,7 +528,7 @@ def restyle(picks, style, cwd): if not full or not os.path.exists(full): out.append(pick) continue - text = full if style == "absolute" else os.path.relpath(full, root) + text = full if style == "absolute" else os.path.relpath(os.path.realpath(full), root) out.append(text + "".join(f":{n}" for n in (line, col) if n is not None)) return out @@ -536,7 +536,11 @@ def restyle(picks, style, cwd): def editor_argv(editor, targets): """argv to edit [(path, line, col)] with the editor command `editor`: Helix and VS Code take `path:line:col`, anything else (vim, nano, emacs) `+LINE` ahead of the files.""" - argv = shlex.split(editor) if editor and editor.strip() else ["vi"] + try: + argv = shlex.split(editor) if editor and editor.strip() else [] + except ValueError: # (unbalanced quotes) + argv = [] + argv = argv or ["vi"] name = os.path.basename(argv[0]) where = lambda p, l, c: p + "".join(f":{n}" for n in (l, c) if n is not None) if l else p if name in ("hx", "helix"): diff --git a/tests/test_extract.py b/tests/test_extract.py index 029e357..83a4cec 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -509,7 +509,11 @@ def test_restyle_paths_relative_to_the_project_or_absolute(tmp_path): picks = ["a.py:3", "gone.py", "echo hi"] assert pt.restyle(picks, "shown", cwd) == picks assert pt.restyle(picks, "relative", cwd) == ["src/a.py:3", "gone.py", "echo hi"] - assert pt.restyle(picks, "absolute", cwd)[0] == f"{(tmp_path / 'src' / 'a.py').resolve() if False else tmp_path / 'src' / 'a.py'}:3" + assert pt.restyle(picks, "absolute", cwd)[0] == f"{tmp_path / 'src' / 'a.py'}:3" + link = tmp_path.parent / (tmp_path.name + "-link") + link.symlink_to(tmp_path) + assert pt.restyle(["a.py:3"], "relative", str(link / "src")) == ["src/a.py:3"] # (cwd reached through a symlink) + assert pt.editor_argv('vim "', [("/t", None, None)]) == ["vi", "/t"] def test_labels_mention_edit_open_and_path_style(monkeypatch): From aee4a5724b37adec40054062c0fdb65296b4c5bf Mon Sep 17 00:00:00 2001 From: Jaeho Shin Date: Tue, 10 Nov 2026 22:02:39 -0800 Subject: [PATCH 3/7] eXtract: M-r for the path style, C-e opens its pane above like the other shortcuts Co-Authored-By: Claude Sonnet 5.5 --- docs/usage-extract.md | 4 ++-- optmux/data/bin/optmux-extract | 22 +++++++++++++++------- tests/test_extract.py | 15 +++++++++++---- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/docs/usage-extract.md b/docs/usage-extract.md index 613c4cb..5314511 100644 --- a/docs/usage-extract.md +++ b/docs/usage-extract.md @@ -4,9 +4,9 @@ - **`C-M-x`** again (the key that opened the picker) switches between **lines** and **spans**. Spans are the runs of text the program colored or styled (the command, path or name worth copying; one the program wrapped onto the next line in the same style is joined back when it clearly filled the line), mixed in screen order with the patterns it did not style (URLs, paths, file names with an optional `:line:col`, commit hashes, PR or issue numbers like `#234`, quoted text (`'x'`, `"x"`, `` `x` ``, GNU `` `x' ``), semver versions). The last one used is remembered. - **`C-y`** copies by the method **`C-t`** cycles: `osc52` (eXtract writes an OSC 52 escape to its own terminal and tmux relays it, so it reaches your local clipboard over SSH too), `native` (`pbcopy`, `wl-copy`, `xclip` or `xsel`) or `buffer` (the tmux buffer only). Also remembered. Either way the tmux buffer holds the text afterwards. -- **`C-e`** edits the picks in `$EDITOR` (`vi` if unset) in a new pane beside the one you came from, in its current directory. File names and paths that exist open as files, at their `:line:col` when they have one (`+LINE` for vim, nvim, nano and emacs, `file:line:col` for Helix, `-g` for VS Code), several picks in one editor; relative ones are taken from that directory, and URLs are left to `C-o`. Anything else (a command, quoted text, lines, a hash) is written to a temporary file that opens instead, and is deleted when you quit the editor. +- **`C-e`** edits the picks in `$EDITOR` (`vi` if unset) in a new pane above the one you came from (as OPTMUX's other shortcuts open theirs), in its current directory. File names and paths that exist open as files, at their `:line:col` when they have one (`+LINE` for vim, nvim, nano and emacs, `file:line:col` for Helix, `-g` for VS Code), several picks in one editor; relative ones are taken from that directory, and URLs are left to `C-o`. Anything else (a command, quoted text, lines, a hash) is written to a temporary file that opens instead, and is deleted when you quit the editor. - **`C-o`** opens each pick by its kind: on macOS a file is revealed in Finder, a directory opened and a URL sent to the browser (`xdg-open` elsewhere); a commit hash or `#234` opens on GitHub with `gh browse`, when `gh` is installed and the pane is in a git repository. What can't be opened (or a path that doesn't exist) is left alone, with a short message saying why. -- **`C-r`** cycles how paths (that exist) are typed and copied: **shown** as on the screen, **relative** to the project root (the pane's git repository, else its directory) or **absolute**; a `:line:col` stays. Remembered like the copy method (`@optmux-extract-paths`). +- **`M-r`** cycles how paths (that exist) are typed and copied: **shown** as on the screen, **relative** to the project root (the pane's git repository, else its directory) or **absolute**; a `:line:col` stays. Remembered like the copy method (`@optmux-extract-paths`). - **Where you were is remembered**, per filter: eXtract opens on the query you left (leaving with `Esc` counts, and an emptied query stays empty), with the cursor on the item you last picked, and `M-p` steps back through that filter's earlier queries before it, each with the cursor on the item you picked then, so the `bash`, `.tmp/` or pattern you use often is a few keystrokes away (`M-n` goes forward again to a fresh, empty start). A query that `M-p` recalls and that matches nothing on the screen now is skipped; the one eXtract opens on stays even then (`C-u` clears it). Spans in plain grey (a coding tool's status lines and hints) are left out, so they don't crowd the list; bold or highlighted ones stay. To find spans starting with a bang, type `^!` or `'!`: a lone `!` is fzf's negation, which matches everything. `C-u` clears the query. The **spans** filter draws each pattern kind with an icon (🔗 URL, 📁 path, 📄 file name, 💬 quoted text, #️⃣ commit hash, 🔀 PR or issue number, 🏷️ version) and the program's own styled runs with 🎨, in the style of `+icons` (`nerd` by default; shown here as emoji) like Find. - **`C-f`, `C-g`, `C-M-f`** look the selection up in the project instead of typing it: the picks (or, with nothing matching, the text you typed in the filter) become the query of [OPTMUX Find](usage-find.md), which continues in this same popup: **`C-f`** in the files, **`C-g`** by grep, **`C-M-f`** in omni (tags, symbols and files). Pick an error's path, a function name or a phrase from the screen, then jump to where it lives (a `:line:col` after a path is left off, since a file name has none). - Several picks go on one line separated by spaces, except lines mode (always one pick per line) and picks that themselves contain whitespace, such as commands in spans mode, which each get their own line. diff --git a/optmux/data/bin/optmux-extract b/optmux/data/bin/optmux-extract index 5d793cc..25b9dab 100755 --- a/optmux/data/bin/optmux-extract +++ b/optmux/data/bin/optmux-extract @@ -14,7 +14,7 @@ cursor), `M-n` forward again to a fresh start (`C-u` clears the query). `icons` text, as in optmux Find) says how a pattern's kind is drawn. `C-e` edits the selection in `$EDITOR` in a new pane (files at their `:line:col`, anything else through a temp file), `C-o` -opens it (files revealed in Finder, URLs in the browser, `#234` and commit hashes on GitHub), and `C-r` cycles how +opens it (files revealed in Finder, URLs in the browser, `#234` and commit hashes on GitHub), and `M-r` cycles how paths are typed and copied: as shown, relative to the project, or absolute. The selection can also be looked up in the project instead: `C-f` finds it among the files, `C-g` greps for it @@ -434,10 +434,10 @@ def labels(filter_name, copier, paths="shown"): do what.""" if shutil.get_terminal_size().columns < NARROW: return [f"--header=Tab: multi; Enter: type; C-y: copy ({copier}; C-t); C-e: edit; C-o: open\n" - f"C-r: paths {paths}; C-u: clear; M-p: older; C-f: files; C-g: grep; C-M-f: all", + f"M-r: paths {paths}; C-u: clear; M-p: older; C-f: files; C-g: grep; C-M-f: all", f"--prompt={filter_name} (C-M-x) > "] return [f"--header=Up/Down to select; Tab to multiselect; Enter to insert; C-y to copy ({copier}; C-t to switch); C-e edit; C-o open\n" - f"C-r: paths {paths}; M-p/M-n: older/newer query; C-u: clear; in the project: C-f files; C-g grep; C-M-f all", + f"M-r: paths {paths}; M-p/M-n: older/newer query; C-u: clear; in the project: C-f files; C-g grep; C-M-f all", f"--prompt={filter_name} filtered (C-M-x to switch) > "] @@ -595,10 +595,18 @@ def edit(pane, chosen, mode): temp = f.name targets = [(temp, None, None)] command = shlex.join(editor_argv(os.environ.get("EDITOR"), targets)) + (f"; rm -f {shlex.quote(temp)}" if temp else "") - tmux("split-window", "-t", pane, "-c", cwd.replace("#", "##"), command) + tmux("split-window", *split_flags(pane), "-t", pane, "-c", cwd.replace("#", "##"), command) return 0 +def split_flags(pane): + """Open above, as optmux's other shortcuts do (the pane keeps its bottom edge), across the window's edge + (-f) when the pane is too short to halve.""" + fits = os.path.join(os.path.dirname(os.path.abspath(__file__)), "optmux-split-fits") + ok = subprocess.run([fits, pane], capture_output=True).returncode == 0 + return ["-v", "-b"] if ok else ["-f", "-v", "-b"] + + def open_command(pick, cwd, system=None): """(argv, message) for opening a pick: a URL in the browser, a path revealed in the file manager, a hash or PR/issue number on GitHub. argv is None when it cannot be opened, and the message says why.""" @@ -689,7 +697,7 @@ def pick(pane): "fzf", "--ansi", "--multi", "--tiebreak=index", *(["--no-sort"] if shown == "lines" else []), "--no-info", "--delimiter=\t", "--with-nth=3", "--print-query", f"--query={query}", *([f"--bind=load:pos({pos})+unbind(load)"] if pos else []), - f"--expect=ctrl-alt-x,ctrl-t,ctrl-y,ctrl-e,ctrl-o,ctrl-r,ctrl-c,esc,alt-p,alt-n,{','.join(FIND_KEYS)}", + f"--expect=ctrl-alt-x,ctrl-t,ctrl-y,ctrl-e,ctrl-o,alt-r,ctrl-c,esc,alt-p,alt-n,{','.join(FIND_KEYS)}", *labels(shown, copier, paths), ], input="\n".join(f"{i}\t{t}\t{d}" for i, (t, d) in numbered), stdout=subprocess.PIPE, text=True, @@ -708,7 +716,7 @@ def pick(pane): recalled = True continue # however you leave (Esc too), the filter is left as it is, an emptied query included: `C-u`, then Esc, stays cleared - if key not in ("ctrl-t", "ctrl-r"): # (switching the copy method stays in the same session, and would shift the M-p index) + if key not in ("ctrl-t", "alt-r"): # (switching the copy method stays in the same session, and would shift the M-p index) save_state(shown, query, chosen[-1] if chosen else remembered) # (the newest pick, or where it was) if key in FIND_KEYS: text = find_query(chosen, query) @@ -721,7 +729,7 @@ def pick(pane): elif key == "ctrl-t": copier = cycle(COPIERS, copier) tmux("set-option", "-g", COPY_OPTION, copier) - elif key == "ctrl-r": + elif key == "alt-r": paths = cycle(PATH_STYLES, paths) tmux("set-option", "-g", PATH_OPTION, paths) elif key in ("ctrl-c", "esc") or not chosen: diff --git a/tests/test_extract.py b/tests/test_extract.py index 83a4cec..be66bb5 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -460,14 +460,15 @@ def test_edit_opens_a_split_in_the_pane_directory_and_removes_its_temp_file(monk calls = [] monkeypatch.setattr(pt, "tmux", lambda *a, **k: calls.append(a) or type("R", (), {"stdout": f"{tmp_path}\n"})()) monkeypatch.setenv("EDITOR", "vim") + monkeypatch.setattr(pt, "split_flags", lambda pane: ["-v", "-b"]) (tmp_path / "a.py").write_text("x") assert pt.edit("%1", ["a.py:7"], "spans") == 0 - assert calls[-1][:5] == ("split-window", "-t", "%1", "-c", str(tmp_path)) - assert calls[-1][5] == f"vim +7 {tmp_path}/a.py" + assert calls[-1][:7] == ("split-window", "-v", "-b", "-t", "%1", "-c", str(tmp_path)) + assert calls[-1][7] == f"vim +7 {tmp_path}/a.py" assert pt.edit("%1", ["echo $(rm -rf x)"], "spans") == 0 import shlex - words = shlex.split(calls[-1][5].split("; rm -f ")[0]) - assert words[0] == "vim" and Path(words[1]).read_text() == "echo $(rm -rf x)\n" and calls[-1][5].endswith(shlex.quote(words[1])) + words = shlex.split(calls[-1][7].split("; rm -f ")[0]) + assert words[0] == "vim" and Path(words[1]).read_text() == "echo $(rm -rf x)\n" and calls[-1][7].endswith(shlex.quote(words[1])) Path(words[1]).unlink() @@ -522,3 +523,9 @@ def test_labels_mention_edit_open_and_path_style(monkeypatch): monkeypatch.setattr(pt.shutil, "get_terminal_size", lambda columns=columns: os.terminal_size((columns, 30))) header = pt.labels("spans", "osc52", "relative")[0] assert "C-e" in header and "C-o" in header and "paths relative" in header + + +def test_the_editor_opens_above_or_across_the_edge_when_the_pane_is_short(monkeypatch): + for code, flags in ((0, ["-v", "-b"]), (1, ["-f", "-v", "-b"])): + monkeypatch.setattr(pt.subprocess, "run", lambda *a, code=code, **k: type("R", (), {"returncode": code})()) + assert pt.split_flags("%1") == flags From a0a3273ab858ffda5329db6a1abe456ea6734bcb Mon Sep 17 00:00:00 2001 From: Jaeho Shin Date: Tue, 10 Nov 2026 22:07:22 -0800 Subject: [PATCH 4/7] eXtract: help text order, 'find selection in' Co-Authored-By: Claude Sonnet 5.5 --- optmux/data/bin/optmux-extract | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/optmux/data/bin/optmux-extract b/optmux/data/bin/optmux-extract index 25b9dab..50dfe27 100755 --- a/optmux/data/bin/optmux-extract +++ b/optmux/data/bin/optmux-extract @@ -434,10 +434,10 @@ def labels(filter_name, copier, paths="shown"): do what.""" if shutil.get_terminal_size().columns < NARROW: return [f"--header=Tab: multi; Enter: type; C-y: copy ({copier}; C-t); C-e: edit; C-o: open\n" - f"M-r: paths {paths}; C-u: clear; M-p: older; C-f: files; C-g: grep; C-M-f: all", + f"C-u: clear; M-p: older; C-f: files; C-g: grep; C-M-f: all; M-r: paths {paths}", f"--prompt={filter_name} (C-M-x) > "] return [f"--header=Up/Down to select; Tab to multiselect; Enter to insert; C-y to copy ({copier}; C-t to switch); C-e edit; C-o open\n" - f"M-r: paths {paths}; M-p/M-n: older/newer query; C-u: clear; in the project: C-f files; C-g grep; C-M-f all", + f"C-u: clear; M-p/M-n: older/newer query; find selection in: C-f files; C-g grep; C-M-f all; M-r: paths {paths}", f"--prompt={filter_name} filtered (C-M-x to switch) > "] From 59cb5e6f6c4590b100b0c95be13c22313306c142 Mon Sep 17 00:00:00 2001 From: Jaeho Shin Date: Tue, 10 Nov 2026 22:10:32 -0800 Subject: [PATCH 5/7] eXtract: group the find keys in the help, M-keys together Co-Authored-By: Claude Sonnet 5.5 --- optmux/data/bin/optmux-extract | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/optmux/data/bin/optmux-extract b/optmux/data/bin/optmux-extract index 50dfe27..f9e1966 100755 --- a/optmux/data/bin/optmux-extract +++ b/optmux/data/bin/optmux-extract @@ -434,10 +434,10 @@ def labels(filter_name, copier, paths="shown"): do what.""" if shutil.get_terminal_size().columns < NARROW: return [f"--header=Tab: multi; Enter: type; C-y: copy ({copier}; C-t); C-e: edit; C-o: open\n" - f"C-u: clear; M-p: older; C-f: files; C-g: grep; C-M-f: all; M-r: paths {paths}", + f"C-u: clear; C-f: files, C-g: grep, C-M-f: all; M-p: older; M-r: paths {paths}", f"--prompt={filter_name} (C-M-x) > "] return [f"--header=Up/Down to select; Tab to multiselect; Enter to insert; C-y to copy ({copier}; C-t to switch); C-e edit; C-o open\n" - f"C-u: clear; M-p/M-n: older/newer query; find selection in: C-f files; C-g grep; C-M-f all; M-r: paths {paths}", + f"C-u: clear; find selection in: C-f files, C-g grep, C-M-f all; M-p/M-n: older/newer query; M-r: paths {paths}", f"--prompt={filter_name} filtered (C-M-x to switch) > "] From 93486f50c4b365b220a655f90770b7d918cf5d57 Mon Sep 17 00:00:00 2001 From: Jaeho Shin Date: Tue, 10 Nov 2026 22:13:58 -0800 Subject: [PATCH 6/7] troubleshooting: popups don't grow on resize Co-Authored-By: Claude Sonnet 5.5 --- docs/troubleshooting/popups-do-not-grow.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/troubleshooting/popups-do-not-grow.md diff --git a/docs/troubleshooting/popups-do-not-grow.md b/docs/troubleshooting/popups-do-not-grow.md new file mode 100644 index 0000000..6645d4c --- /dev/null +++ b/docs/troubleshooting/popups-do-not-grow.md @@ -0,0 +1,13 @@ +# Popups don't grow when the terminal gets wider + +## Problem + +OPTMUX eXtract, Find and the tips screen open as tmux popups (`display-popup -w 100% -h 60%`). Make the terminal narrower while one is open and it follows; make it wider again and it stays as wide as when it opened. + +## Cause + +tmux turns the percentage into a fixed size when the popup opens. A resize only shrinks a popup that no longer fits, and never grows it back. fzf inside simply fills what it is given, so there is nothing OPTMUX can do from within the popup. + +## Workaround + +Close the popup and open it again after resizing. From 21c74137546fe6e6578e8ababe02c995a0842378 Mon Sep 17 00:00:00 2001 From: Jaeho Shin Date: Tue, 10 Nov 2026 22:15:39 -0800 Subject: [PATCH 7/7] eXtract: C-e opens the editor zoomed and gives the zoom back on quit Co-Authored-By: Claude Sonnet 5.5 --- docs/usage-extract.md | 2 +- optmux/data/bin/optmux-extract | 5 ++++- tests/test_extract.py | 22 ++++++++++++++++++---- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/usage-extract.md b/docs/usage-extract.md index 5314511..fd0503e 100644 --- a/docs/usage-extract.md +++ b/docs/usage-extract.md @@ -4,7 +4,7 @@ - **`C-M-x`** again (the key that opened the picker) switches between **lines** and **spans**. Spans are the runs of text the program colored or styled (the command, path or name worth copying; one the program wrapped onto the next line in the same style is joined back when it clearly filled the line), mixed in screen order with the patterns it did not style (URLs, paths, file names with an optional `:line:col`, commit hashes, PR or issue numbers like `#234`, quoted text (`'x'`, `"x"`, `` `x` ``, GNU `` `x' ``), semver versions). The last one used is remembered. - **`C-y`** copies by the method **`C-t`** cycles: `osc52` (eXtract writes an OSC 52 escape to its own terminal and tmux relays it, so it reaches your local clipboard over SSH too), `native` (`pbcopy`, `wl-copy`, `xclip` or `xsel`) or `buffer` (the tmux buffer only). Also remembered. Either way the tmux buffer holds the text afterwards. -- **`C-e`** edits the picks in `$EDITOR` (`vi` if unset) in a new pane above the one you came from (as OPTMUX's other shortcuts open theirs), in its current directory. File names and paths that exist open as files, at their `:line:col` when they have one (`+LINE` for vim, nvim, nano and emacs, `file:line:col` for Helix, `-g` for VS Code), several picks in one editor; relative ones are taken from that directory, and URLs are left to `C-o`. Anything else (a command, quoted text, lines, a hash) is written to a temporary file that opens instead, and is deleted when you quit the editor. +- **`C-e`** edits the picks in `$EDITOR` (`vi` if unset) in a new, zoomed pane above the one you came from (as OPTMUX's other shortcuts open theirs; quitting the editor brings back the layout, or the zoom you had), in its current directory. File names and paths that exist open as files, at their `:line:col` when they have one (`+LINE` for vim, nvim, nano and emacs, `file:line:col` for Helix, `-g` for VS Code), several picks in one editor; relative ones are taken from that directory, and URLs are left to `C-o`. Anything else (a command, quoted text, lines, a hash) is written to a temporary file that opens instead, and is deleted when you quit the editor. - **`C-o`** opens each pick by its kind: on macOS a file is revealed in Finder, a directory opened and a URL sent to the browser (`xdg-open` elsewhere); a commit hash or `#234` opens on GitHub with `gh browse`, when `gh` is installed and the pane is in a git repository. What can't be opened (or a path that doesn't exist) is left alone, with a short message saying why. - **`M-r`** cycles how paths (that exist) are typed and copied: **shown** as on the screen, **relative** to the project root (the pane's git repository, else its directory) or **absolute**; a `:line:col` stays. Remembered like the copy method (`@optmux-extract-paths`). - **Where you were is remembered**, per filter: eXtract opens on the query you left (leaving with `Esc` counts, and an emptied query stays empty), with the cursor on the item you last picked, and `M-p` steps back through that filter's earlier queries before it, each with the cursor on the item you picked then, so the `bash`, `.tmp/` or pattern you use often is a few keystrokes away (`M-n` goes forward again to a fresh, empty start). A query that `M-p` recalls and that matches nothing on the screen now is skipped; the one eXtract opens on stays even then (`C-u` clears it). Spans in plain grey (a coding tool's status lines and hints) are left out, so they don't crowd the list; bold or highlighted ones stay. To find spans starting with a bang, type `^!` or `'!`: a lone `!` is fzf's negation, which matches everything. `C-u` clears the query. The **spans** filter draws each pattern kind with an icon (🔗 URL, 📁 path, 📄 file name, 💬 quoted text, #️⃣ commit hash, 🔀 PR or issue number, 🏷️ version) and the program's own styled runs with 🎨, in the style of `+icons` (`nerd` by default; shown here as emoji) like Find. diff --git a/optmux/data/bin/optmux-extract b/optmux/data/bin/optmux-extract index f9e1966..57f9f84 100755 --- a/optmux/data/bin/optmux-extract +++ b/optmux/data/bin/optmux-extract @@ -595,7 +595,10 @@ def edit(pane, chosen, mode): temp = f.name targets = [(temp, None, None)] command = shlex.join(editor_argv(os.environ.get("EDITOR"), targets)) + (f"; rm -f {shlex.quote(temp)}" if temp else "") - tmux("split-window", *split_flags(pane), "-t", pane, "-c", cwd.replace("#", "##"), command) + # the editor is zoomed (-Z); when the window was zoomed on this pane already, quitting the editor hands the zoom back to it + if tmux("display-message", "-p", "-t", pane, "#{window_zoomed_flag}", capture_output=True, text=True).stdout.strip() == "1": + command += f"; tmux select-pane -Z -t {shlex.quote(pane)}" + tmux("split-window", "-Z", *split_flags(pane), "-t", pane, "-c", cwd.replace("#", "##"), command) return 0 diff --git a/tests/test_extract.py b/tests/test_extract.py index be66bb5..607ef7b 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -463,12 +463,12 @@ def test_edit_opens_a_split_in_the_pane_directory_and_removes_its_temp_file(monk monkeypatch.setattr(pt, "split_flags", lambda pane: ["-v", "-b"]) (tmp_path / "a.py").write_text("x") assert pt.edit("%1", ["a.py:7"], "spans") == 0 - assert calls[-1][:7] == ("split-window", "-v", "-b", "-t", "%1", "-c", str(tmp_path)) - assert calls[-1][7] == f"vim +7 {tmp_path}/a.py" + assert calls[-1][:8] == ("split-window", "-Z", "-v", "-b", "-t", "%1", "-c", str(tmp_path)) + assert calls[-1][8] == f"vim +7 {tmp_path}/a.py" assert pt.edit("%1", ["echo $(rm -rf x)"], "spans") == 0 import shlex - words = shlex.split(calls[-1][7].split("; rm -f ")[0]) - assert words[0] == "vim" and Path(words[1]).read_text() == "echo $(rm -rf x)\n" and calls[-1][7].endswith(shlex.quote(words[1])) + words = shlex.split(calls[-1][8].split("; rm -f ")[0]) + assert words[0] == "vim" and Path(words[1]).read_text() == "echo $(rm -rf x)\n" and calls[-1][8].endswith(shlex.quote(words[1])) Path(words[1]).unlink() @@ -529,3 +529,17 @@ def test_the_editor_opens_above_or_across_the_edge_when_the_pane_is_short(monkey for code, flags in ((0, ["-v", "-b"]), (1, ["-f", "-v", "-b"])): monkeypatch.setattr(pt.subprocess, "run", lambda *a, code=code, **k: type("R", (), {"returncode": code})()) assert pt.split_flags("%1") == flags + + +def test_the_editor_is_zoomed_and_gives_the_zoom_back_when_the_window_was_zoomed(monkeypatch, tmp_path): + calls = [] + def fake_tmux(*a, **k): + calls.append(a) + return type("R", (), {"stdout": "1\n" if "#{window_zoomed_flag}" in a else f"{tmp_path}\n"})() + monkeypatch.setattr(pt, "tmux", fake_tmux) + monkeypatch.setattr(pt, "split_flags", lambda pane: ["-v", "-b"]) + monkeypatch.setenv("EDITOR", "vim") + (tmp_path / "a.py").write_text("x") + pt.edit("%1", ["a.py"], "spans") + split = calls[-1] + assert split[1] == "-Z" and split[-1].endswith("; tmux select-pane -Z -t %1")