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
42 changes: 23 additions & 19 deletions app/lib/linear_cli/cli/display.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -15,55 +16,58 @@ 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
# existed to display one). `LinearCli.CLI.IssueHelpers.issue_comment/2`/
# `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
Expand Down
93 changes: 93 additions & 0 deletions app/lib/linear_cli/cli/pager.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading