diff --git a/app/lib/linear_cli/cli/display.ex b/app/lib/linear_cli/cli/display.ex index 62f3415..405a154 100644 --- a/app/lib/linear_cli/cli/display.ex +++ b/app/lib/linear_cli/cli/display.ex @@ -7,6 +7,7 @@ defmodule LinearCli.CLI.Display do own `#to_s`/`#full`/`#display` methods. """ + alias LinearCli.CLI.Pager alias LinearCli.Linear.{Comment, Issue, Project, ProjectUpdate, Team, User} alias LinearCli.Profiles.Profile @@ -15,26 +16,31 @@ defmodule LinearCli.CLI.Display do @doc """ Prints `subject` (a resource, or a list of resources) per `opts[:output]` (`"text"`, the default, or `"json"`). + + Text output is routed through `$PAGER` (see `LinearCli.CLI.Pager`) when + stdout is a terminal and the content exceeds the terminal height. + `--output json` is never paged. """ def show(subject, opts \\ %{}) do if Map.get(opts, :output, "text") == "json" do subject |> to_plain() |> Jason.encode!(pretty: true) |> IO.puts() else - subject |> List.wrap() |> Enum.each(&puts_text(&1, opts)) + text = subject |> List.wrap() |> Enum.map_join("\n", &format(&1, opts)) + Pager.maybe_page(text, opts) end end - defp puts_text(%Team{} = team, _opts) do - IO.puts("#{String.pad_trailing(team.key || "", 6)} #{team.name}") + defp format(%Team{} = team, _opts) do + "#{String.pad_trailing(team.key || "", 6)} #{team.name}" end - defp puts_text(%Project{} = project, _opts) do - IO.puts("#{String.pad_trailing(project.name || "", 12)} #{project.url}") + defp format(%Project{} = project, _opts) do + "#{String.pad_trailing(project.name || "", 12)} #{project.url}" end - defp puts_text(%ProjectUpdate{} = update, _opts) do + defp format(%ProjectUpdate{} = update, _opts) do health = if update.health, do: " (#{update.health})", else: "" - IO.puts("Posted#{health}: #{update.url}") + "Posted#{health}: #{update.url}" end # New in this port - Ruby has no equivalent (no bare `Comment` command @@ -42,28 +48,26 @@ defmodule LinearCli.CLI.Display do # `upsert_comment/4` already print a "Comment added to.../updated on..." # confirmation via `Prompt.ok/1` before this runs, so this only needs to # add the one thing that isn't in that line: a link to the comment. - defp puts_text(%Comment{} = comment, _opts) do - IO.puts(comment.url || "(no URL returned)") + defp format(%Comment{} = comment, _opts) do + comment.url || "(no URL returned)" end - defp puts_text(%Profile{} = profile, _opts) do + defp format(%Profile{} = profile, _opts) do marker = if profile.active, do: "* ", else: " " - IO.puts( - "#{marker}#{String.pad_trailing(profile.name, 12)} team=#{profile.team || "-"} project=#{profile.project || "-"}" - ) + "#{marker}#{String.pad_trailing(profile.name, 12)} team=#{profile.team || "-"} project=#{profile.project || "-"}" end - defp puts_text(%User{} = user, opts) do - IO.puts(user_line(user, opts)) + defp format(%User{} = user, opts) do + user_line(user, opts) end - defp puts_text(%Issue{} = issue, %{full: true}) do - IO.puts(issue_full(issue)) + defp format(%Issue{} = issue, %{full: true}) do + issue_full(issue) end - defp puts_text(%Issue{} = issue, _opts) do - IO.puts(issue_line(issue)) + defp format(%Issue{} = issue, _opts) do + issue_line(issue) end defp user_line(user, opts) do diff --git a/app/lib/linear_cli/cli/pager.ex b/app/lib/linear_cli/cli/pager.ex new file mode 100644 index 0000000..f003b08 --- /dev/null +++ b/app/lib/linear_cli/cli/pager.ex @@ -0,0 +1,93 @@ +defmodule LinearCli.CLI.Pager do + @moduledoc """ + Routes text output through `$PAGER` when stdout is a real terminal and + the content exceeds the terminal height — matching the behavior of + `git log`, `gh pr view`, and similar CLI tools. + + Paging is skipped when any of the following is true: + + - stdout is not a terminal (`Owl.IO.rows/0` returns `nil`) + - `$PAGER` is `""` or `"cat"` (user has explicitly disabled paging) + - The content's line count does not exceed the terminal's row count + + When paging is needed the content is written to a temp file and the + pager is invoked via `System.shell/1`, which — like + `Owl.IO.open_in_editor/2`'s own use of the same function — lets the + child process open `/dev/tty` directly for interactive keyboard + control. + + ## Testing + + In tests, `Owl.IO.rows/0` returns `nil` (no real terminal), so paging + is automatically skipped and `IO.puts/1` is called instead — + `capture_io` keeps working with no changes to existing tests. + + To exercise the pager path directly, pass injectable overrides: + + - `rows_fn: fn -> 24 end` — simulate a 24-row terminal + - `shell_fn: fn cmd -> ... end` — capture or mock the shell invocation + """ + + @default_pager "less -FRX" + + @doc """ + Prints `text` directly via `IO.puts/1`, or through `$PAGER` when + stdout is a terminal and the content exceeds the terminal height. + + `opts` accepts injectable overrides for testing: + - `:rows_fn` — 0-arity function returning terminal row count or `nil` + (defaults to `&Owl.IO.rows/0`) + - `:shell_fn` — 1-arity function receiving the full shell command + (defaults to `&System.shell/1`) + """ + @spec maybe_page(String.t(), map()) :: :ok + def maybe_page(text, opts \\ %{}) do + rows_fn = Map.get(opts, :rows_fn, &Owl.IO.rows/0) + shell_fn = Map.get(opts, :shell_fn, &System.shell/1) + terminal_rows = rows_fn.() + pager = resolve_pager() + + if should_page?(text, terminal_rows, pager) do + invoke_pager(text, pager, shell_fn) + else + IO.puts(text) + end + end + + defp should_page?(_text, nil, _pager), do: false + defp should_page?(_text, _rows, nil), do: false + defp should_page?(text, terminal_rows, _pager), do: count_lines(text) > terminal_rows + + defp resolve_pager do + case System.get_env("PAGER") do + nil -> @default_pager + "" -> nil + "cat" -> nil + pager -> pager + end + end + + defp count_lines(text) do + text |> String.trim_trailing("\n") |> String.split("\n") |> length() + end + + defp invoke_pager(text, pager, shell_fn) do + path = Path.join(System.tmp_dir!(), "lc-pager-#{System.unique_integer([:positive])}") + File.write!(path, text) + + try do + case shell_fn.("#{pager} #{shell_quote(path)}") do + {_, 0} -> :ok + _ -> IO.puts(text) + end + after + File.rm(path) + end + + :ok + end + + defp shell_quote(path) do + "'" <> String.replace(path, "'", "'\\''") <> "'" + end +end diff --git a/app/test/linear_cli/cli/pager_test.exs b/app/test/linear_cli/cli/pager_test.exs new file mode 100644 index 0000000..3cec1da --- /dev/null +++ b/app/test/linear_cli/cli/pager_test.exs @@ -0,0 +1,244 @@ +defmodule LinearCli.CLI.PagerTest do + # Not async: some tests set PAGER env var + use ExUnit.Case + + import ExUnit.CaptureIO + + alias LinearCli.CLI.Pager + + # 50 lines — more than any reasonable terminal height in tests + @long_text String.duplicate("line\n", 50) + @short_text "just one line" + + describe "maybe_page/2 - not a TTY" do + test "prints directly without invoking the pager" do + {invoked, shell_fn} = spy_shell() + + output = + capture_io(fn -> + Pager.maybe_page(@long_text, %{rows_fn: fn -> nil end, shell_fn: shell_fn}) + end) + + assert output =~ "line" + assert invoked.() == 0 + end + end + + describe "maybe_page/2 - $PAGER disabled" do + test "PAGER='' prints directly" do + with_env("PAGER", "", fn -> + {invoked, shell_fn} = spy_shell() + + output = + capture_io(fn -> + Pager.maybe_page(@long_text, %{rows_fn: fn -> 5 end, shell_fn: shell_fn}) + end) + + assert output =~ "line" + assert invoked.() == 0 + end) + end + + test "PAGER=cat prints directly" do + with_env("PAGER", "cat", fn -> + {invoked, shell_fn} = spy_shell() + + output = + capture_io(fn -> + Pager.maybe_page(@long_text, %{rows_fn: fn -> 5 end, shell_fn: shell_fn}) + end) + + assert output =~ "line" + assert invoked.() == 0 + end) + end + end + + describe "maybe_page/2 - content fits on screen" do + test "prints directly when line count <= terminal height" do + {invoked, shell_fn} = spy_shell() + + output = + capture_io(fn -> + Pager.maybe_page(@short_text, %{rows_fn: fn -> 40 end, shell_fn: shell_fn}) + end) + + assert output =~ "just one line" + assert invoked.() == 0 + end + + test "does not page when content is exactly terminal height" do + # 24 lines, 24-row terminal → fits (not strictly greater) + text = String.duplicate("x\n", 24) + {invoked, shell_fn} = spy_shell() + + capture_io(fn -> + Pager.maybe_page(text, %{rows_fn: fn -> 24 end, shell_fn: shell_fn}) + end) + + assert invoked.() == 0 + end + end + + describe "maybe_page/2 - content exceeds terminal height" do + test "invokes the pager" do + {invoked, shell_fn} = spy_shell() + + capture_io(fn -> + Pager.maybe_page(@long_text, %{rows_fn: fn -> 5 end, shell_fn: shell_fn}) + end) + + assert invoked.() == 1 + end + + test "uses less -FRX by default (when $PAGER is unset)" do + with_env("PAGER", nil, fn -> + commands = + capture_commands(fn shell_fn -> + capture_io(fn -> + Pager.maybe_page(@long_text, %{rows_fn: fn -> 5 end, shell_fn: shell_fn}) + end) + end) + + assert length(commands) == 1 + assert String.starts_with?(hd(commands), "less -FRX ") + end) + end + + test "uses $PAGER when set" do + with_env("PAGER", "more", fn -> + commands = + capture_commands(fn shell_fn -> + capture_io(fn -> + Pager.maybe_page(@long_text, %{rows_fn: fn -> 5 end, shell_fn: shell_fn}) + end) + end) + + assert length(commands) == 1 + assert String.starts_with?(hd(commands), "more ") + end) + end + + test "writes content to a temp file and passes the path to the pager" do + content_received = :erlang.make_ref() + + shell_fn = fn cmd -> + path = extract_path(cmd) + send(self(), {content_received, File.read!(path)}) + {"", 0} + end + + capture_io(fn -> + Pager.maybe_page(@long_text, %{rows_fn: fn -> 5 end, shell_fn: shell_fn}) + end) + + assert_receive {^content_received, content} + assert content == @long_text + end + + test "cleans up the temp file after the pager exits" do + path_received = :erlang.make_ref() + + shell_fn = fn cmd -> + send(self(), {path_received, extract_path(cmd)}) + {"", 0} + end + + capture_io(fn -> + Pager.maybe_page(@long_text, %{rows_fn: fn -> 5 end, shell_fn: shell_fn}) + end) + + assert_receive {^path_received, path} + refute File.exists?(path) + end + + test "cleans up the temp file even when the pager raises" do + path_received = :erlang.make_ref() + + shell_fn = fn cmd -> + send(self(), {path_received, extract_path(cmd)}) + raise "pager crashed" + end + + assert_raise RuntimeError, fn -> + capture_io(fn -> + Pager.maybe_page(@long_text, %{rows_fn: fn -> 5 end, shell_fn: shell_fn}) + end) + end + + assert_receive {^path_received, path} + refute File.exists?(path) + end + + test "falls back to IO.puts when the pager exits with non-zero status" do + shell_fn = fn _cmd -> {"", 127} end + + output = + capture_io(fn -> + Pager.maybe_page(@long_text, %{rows_fn: fn -> 5 end, shell_fn: shell_fn}) + end) + + assert output =~ "line" + end + end + + # Helpers + + defp spy_shell do + {:ok, counter} = Agent.start_link(fn -> 0 end) + + shell_fn = fn _cmd -> + Agent.update(counter, &(&1 + 1)) + {"", 0} + end + + {fn -> Agent.get(counter, & &1) end, shell_fn} + end + + defp capture_commands(fun) do + {:ok, agent} = Agent.start_link(fn -> [] end) + + shell_fn = fn cmd -> + Agent.update(agent, &[cmd | &1]) + {"", 0} + end + + fun.(shell_fn) + Agent.get(agent, &Enum.reverse/1) + end + + # Extracts the temp file path from a single-quoted shell command. + # Command format: "less -FRX '/tmp/lc-pager-12345'" + defp extract_path(cmd) do + [_, quoted] = Regex.run(~r/ '(.+)'$/, cmd) + quoted + end + + defp with_env(key, nil, fun) do + original = System.get_env(key) + System.delete_env(key) + + try do + fun.() + after + case original do + nil -> System.delete_env(key) + v -> System.put_env(key, v) + end + end + end + + defp with_env(key, value, fun) do + original = System.get_env(key) + System.put_env(key, value) + + try do + fun.() + after + case original do + nil -> System.delete_env(key) + v -> System.put_env(key, v) + end + end + end +end