Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "Copy to Markdown" button #1111

Merged
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
64 changes: 32 additions & 32 deletions lib/plug/debugger.ex
Expand Up @@ -187,8 +187,16 @@ defmodule Plug.Debugger do
session = maybe_fetch_session(conn)
params = maybe_fetch_query_params(conn)
{title, message} = info(kind, reason)
style = Enum.into(opts[:style] || [], @default_style)
banner = banner(conn, status, kind, reason, stack, opts)

assigns = [
conn: conn,
title: title,
formatted: Exception.format(kind, reason, stack),
session: session,
params: params,
frames: frames(:md, stack, opts)
]
markdown = template_markdown(assigns)

if accepts_html?(get_req_header(conn, "accept")) do
conn =
Expand All @@ -198,35 +206,24 @@ defmodule Plug.Debugger do

actions = encoded_actions_for_exception(reason, conn)
last_path = actions_redirect_path(conn)
style = Enum.into(opts[:style] || [], @default_style)
banner = banner(conn, status, kind, reason, stack, opts)

assigns = [
assigns = Keyword.merge(assigns, [
conn: conn,
frames: frames(stack, opts),
title: title,
message: message,
session: session,
params: params,
markdown: markdown,
style: style,
banner: banner,
actions: actions,
frames: frames(:html, stack, opts),
last_path: last_path
]
])

send_resp(conn, status, template_html(assigns))
else
{reason, stack} = Exception.blame(kind, reason, stack)

conn = put_resp_content_type(conn, "text/markdown")

assigns = [
conn: conn,
title: title,
formatted: Exception.format(kind, reason, stack),
session: session,
params: params
]

send_resp(conn, status, template_markdown(assigns))
send_resp(conn, status, markdown)
end
end

Expand Down Expand Up @@ -316,21 +313,21 @@ defmodule Plug.Debugger do
defp info(:throw, thrown), do: {"unhandled throw", inspect(thrown)}
defp info(:exit, reason), do: {"unhandled exit", Exception.format_exit(reason)}

defp frames(stacktrace, opts) do
defp frames(renderer, stacktrace, opts) do
app = opts[:otp_app]
editor = System.get_env("PLUG_EDITOR")

stacktrace
|> Enum.map_reduce(0, &each_frame(&1, &2, app, editor))
|> Enum.map_reduce(0, &each_frame(&1, &2, renderer, app, editor))
|> elem(0)
end

defp each_frame(entry, index, root, editor) do
defp each_frame(entry, index, renderer, root, editor) do
{module, info, location, app, fun, arity, args} = get_entry(entry)
{file, line} = {to_string(location[:file] || "nofile"), location[:line]}

doc = module && get_doc(module, fun, arity, app)
clauses = module && get_clauses(module, fun, args)
clauses = module && get_clauses(renderer, module, fun, args)
source = get_source(app, module, file)
context = get_context(root, app)
snippet = get_snippet(source, line)
Expand Down Expand Up @@ -415,16 +412,16 @@ defmodule Plug.Debugger do
)
end

defp get_clauses(module, fun, args) do
defp get_clauses(renderer, module, fun, args) do
with true <- is_list(args),
{:ok, kind, clauses} <- Exception.blame_mfa(module, fun, args) do
top_10 =
clauses
|> Enum.take(10)
|> Enum.map(fn {args, guards} ->
args = Enum.map_join(args, ", ", &blame_match/1)
args = Enum.map_join(args, ", ", &blame_match(renderer, &1))
base = "#{kind} #{fun}(#{args})"
Enum.reduce(guards, base, &"#{&2} when #{blame_clause(&1)}")
Enum.reduce(guards, base, &"#{&2} when #{blame_clause(renderer, &1)}")
end)

{length(top_10), length(clauses), top_10}
Expand All @@ -433,16 +430,19 @@ defmodule Plug.Debugger do
end
end

defp blame_match(%{match?: true, node: node}),
defp blame_match(:html, %{match?: true, node: node}),
do: ~s(<i class="green">) <> h(Macro.to_string(node)) <> "</i>"

defp blame_match(%{match?: false, node: node}),
defp blame_match(:html, %{match?: false, node: node}),
do: ~s(<i class="red">) <> h(Macro.to_string(node)) <> "</i>"

defp blame_clause({op, _, [left, right]}),
do: blame_clause(left) <> " #{op} " <> blame_clause(right)
defp blame_match(_md, %{node: node}),
do: h(Macro.to_string(node))

defp blame_clause(renderer, {op, _, [left, right]}),
do: blame_clause(renderer, left) <> " #{op} " <> blame_clause(renderer, right)

defp blame_clause(node), do: blame_match(node)
defp blame_clause(renderer, node), do: blame_match(renderer, node)

defp get_context(app, app) when app != nil, do: :app
defp get_context(_app1, _app2), do: :all
Expand Down
75 changes: 63 additions & 12 deletions lib/plug/templates/debugger.html.eex
Expand Up @@ -202,6 +202,11 @@
padding-left: 32px;
}

.code-explorer .hidden-contents {
position: absolute;
left: -999em;
}

/* Collapse to single-column */
@media (max-width: 960px) {
.code-explorer > .code-snippets {
Expand Down Expand Up @@ -400,25 +405,18 @@
*/

.stack-trace-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 8px;
}

.stack-trace-heading:after {
content: '';
display: block;
clear: both;
zoom: 1;
border-bottom: solid 1px <%= @style.line_color %>;
padding-top: 12px;
margin-bottom: 16px;
}

.stack-trace-heading > h3 {
display: none;
}

.stack-trace-heading > label {
display: block;
display: inline-block;
padding-left: 8px;
line-height: 1.9;
font-size: <%= :math.pow(1.2, -1) %>em;
Expand All @@ -430,6 +428,32 @@

.stack-trace-heading > label > input {
margin-right: .3em;
vertical-align: middle;
}

.stack-trace-heading .copy-markdown {
color: <%= @style.text_color %>;
background-color: transparent;
display: inline-flex;
align-items: center;
font-size: <%= :math.pow(1.2, -1) %>em;
line-height: 1.9;
border-width: 0;
}

.stack-trace-heading .copy-markdown:active {
cursor: pointer;
}

.stack-trace-heading .copy-markdown:hover {
cursor: pointer;
}

.stack-trace-heading .copy-markdown-icon {
height: 1rem;
width: 1rem;
margin-left: 0.5rem;
margin-right: -0.125rem;
}

@media (max-width: 480px) {
Expand Down Expand Up @@ -709,11 +733,12 @@
<% end %>

<div class="code-explorer">
<textarea class="hidden-contents" role="copy-contents"><%= @markdown %></textarea>
<div class="code-snippets">
<%= for frame <- @frames do %>
<div class="frame-info" data-index="<%= frame.index %>" role="stack-trace-details">
<div class="file">
<a href="<%= frame.link %>"><%= h frame.file %></a>
<a href="<%= frame.link %>"><%= h frame.file %></a>
</div>

<%= if (snippet = frame.snippet) && snippet != [] do %>
Expand Down Expand Up @@ -764,6 +789,10 @@
<div class="stack-trace">
<div class="stack-trace-heading">
<label><input type="checkbox" role="show-all-toggle">Show only app frames</label>
<button class="copy-markdown" role="copy-to-markdown" type="button">
<span role="copy-to-markdown-text">Copy markdown</span>
<svg xmlns="http://www.w3.org/2000/svg" class="copy-markdown-icon" viewBox="0 0 115.77 122.88"><g><path d="M89.62,13.96v7.73h12.19h0.01v0.02c3.85,0.01,7.34,1.57,9.86,4.1c2.5,2.51,4.06,5.98,4.07,9.82h0.02v0.02 v73.27v0.01h-0.02c-0.01,3.84-1.57,7.33-4.1,9.86c-2.51,2.5-5.98,4.06-9.82,4.07v0.02h-0.02h-61.7H40.1v-0.02 c-3.84-0.01-7.34-1.57-9.86-4.1c-2.5-2.51-4.06-5.98-4.07-9.82h-0.02v-0.02V92.51H13.96h-0.01v-0.02c-3.84-0.01-7.34-1.57-9.86-4.1 c-2.5-2.51-4.06-5.98-4.07-9.82H0v-0.02V13.96v-0.01h0.02c0.01-3.85,1.58-7.34,4.1-9.86c2.51-2.5,5.98-4.06,9.82-4.07V0h0.02h61.7 h0.01v0.02c3.85,0.01,7.34,1.57,9.86,4.1c2.5,2.51,4.06,5.98,4.07,9.82h0.02V13.96L89.62,13.96z M79.04,21.69v-7.73v-0.02h0.02 c0-0.91-0.39-1.75-1.01-2.37c-0.61-0.61-1.46-1-2.37-1v0.02h-0.01h-61.7h-0.02v-0.02c-0.91,0-1.75,0.39-2.37,1.01 c-0.61,0.61-1,1.46-1,2.37h0.02v0.01v64.59v0.02h-0.02c0,0.91,0.39,1.75,1.01,2.37c0.61,0.61,1.46,1,2.37,1v-0.02h0.01h12.19V35.65 v-0.01h0.02c0.01-3.85,1.58-7.34,4.1-9.86c2.51-2.5,5.98-4.06,9.82-4.07v-0.02h0.02H79.04L79.04,21.69z M105.18,108.92V35.65v-0.02 h0.02c0-0.91-0.39-1.75-1.01-2.37c-0.61-0.61-1.46-1-2.37-1v0.02h-0.01h-61.7h-0.02v-0.02c-0.91,0-1.75,0.39-2.37,1.01 c-0.61,0.61-1,1.46-1,2.37h0.02v0.01v73.27v0.02h-0.02c0,0.91,0.39,1.75,1.01,2.37c0.61,0.61,1.46,1,2.37,1v-0.02h0.01h61.7h0.02 v0.02c0.91,0,1.75-0.39,2.37-1.01c0.61-0.61,1-1.46,1-2.37h-0.02V108.92L105.18,108.92z"/></g></svg>
</button>
</div>

<ul class="stack-trace-list -show-all" role="stack-trace-list">
Expand Down Expand Up @@ -843,12 +872,34 @@
var $items = document.querySelectorAll('[role~="stack-trace-item"]')
var $toggle = document.querySelector('[role~="show-all-toggle"]')
var $list = document.querySelector('[role~="stack-trace-list"]')
var $copyBtn = document.querySelector('[role~="copy-to-markdown"]')
var $copyBtnText = document.querySelector('[role~="copy-to-markdown-text"]')
var $copy = document.querySelector('[role~="copy-contents"]')

each($items, function ($item) {
on($item, 'click', itemOnclick)
})

on($toggle, 'click', toggleOnclick)
on($copyBtn, 'click', copyToClipboard)

function copyToClipboard () {
if(navigator.clipboard) {
// For those working on localhost or HTTPS
navigator.clipboard.writeText($copy.innerHTML).then(copiedClipboard).catch(() => {})
} else {
// For those working on HTTP
$copy.select()
if (document.execCommand("copy")) copiedClipboard()
}
}

function copiedClipboard () {
$copyBtnText.innerText = "Copied!"
setTimeout(function () {
$copyBtnText.innerText = "Copy markdown"
}, 5000)
}

function toggleOnclick () {
if (this.checked) {
Expand Down
18 changes: 18 additions & 0 deletions lib/plug/templates/debugger.md.eex
Expand Up @@ -4,6 +4,24 @@ Exception:

<%= String.replace(@formatted, "\n", "\n ") %>

Code:
<%= for frame <- @frames do %>
`<%= h frame.file %>`
<%= if (snippet = frame.snippet) && snippet != [] do %>
<%= for {index, line, highlight} <- snippet do %><%= if highlight do %><%= h index %>> <% else %><%= h index %> <% end %><%= h String.trim_trailing(line) %>
<% end %><% else %>
No code available.
<% end %><%= if frame.args do %>
Called with <%= length(frame.args) %> arguments

<%= for arg <- frame.args do %>* `<%= h inspect arg %>`
<% end %><% end %><%= if frame.clauses do %><% {min, max, clauses} = frame.clauses %>
Attempted function clauses (showing <%= min %> out of <%= max %>)

<%= for clause <- clauses do %> <%= clause %>
<% end %>
<% end %><% end %>

## Connection details

### Params
Expand Down
34 changes: 34 additions & 0 deletions test/plug/debugger_test.exs
Expand Up @@ -51,6 +51,11 @@ defmodule Plug.DebuggerTest do
raise "oops"
end

get "/bad_match" do
_ = conn
bad_match(:six, :one)
end

get "/send_and_wrapped" do
stack =
try do
Expand All @@ -75,6 +80,10 @@ defmodule Plug.DebuggerTest do

defp add_csp(conn, _opts),
do: Plug.Conn.put_resp_header(conn, "content-security-policy", "abcdef")

defp bad_match(:one, :two), do: :ok
defp bad_match(:three, :four), do: :ok
defp bad_match(:five, :six), do: :ok
end

defmodule StyledRouter do
Expand Down Expand Up @@ -294,6 +303,15 @@ defmodule Plug.DebuggerTest do
assert conn.resp_body =~ "session_value"
end

test "shows copy markdown button" do
conn =
conn(:get, "/")
|> put_req_header("accept", "text/html")
|> render([], fn -> raise "oops" end)

assert conn.resp_body =~ "Copy markdown"
end

defp stack(stack) do
render(put_req_header(conn(:get, "/"), "accept", "text/html"), [stack: stack], fn ->
raise "oops"
Expand Down Expand Up @@ -365,6 +383,22 @@ defmodule Plug.DebuggerTest do
assert conn.resp_body =~ "unsupported media type foo/bar"
end

test "renders bad match attempted arguments" do
conn =
conn(:get, "/bad_match")
|> put_req_header("accept", "text/markdown")
|> put_resp_header("content-security-policy", "abcdef")

capture_log(fn -> assert_raise(FunctionClauseError, fn -> Router.call(conn, []) end) end)
{_status, _headers, body} = sent_resp(conn)

assert body =~ "# FunctionClauseError at GET /bad_match"
assert body =~ "Code:\n"
assert body =~ " Called with 2 arguments"
assert body =~ " * `:six`"
assert body =~ " * `:one`"
end

test "render actions when an implementation of `Plug.Exception` has it" do
[%{label: action_label}] = Plug.Exception.actions(%ActionableError{})

Expand Down