From a9e746d520adf8fa955b0f5cdcdcfcf57a456d9d Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Mon, 18 May 2026 21:31:01 +0100 Subject: [PATCH] scroll and highlight selected line added line numbers to source view, fixed css selectors --- app/assets/css/app.css | 141 +++++++++--------- app/assets/js/hooks/source_viewer.js | 35 ++++- app/lib/firehose_web/live/microprints_live.ex | 37 +++-- .../firehose_web/live/source_viewer_test.exs | 97 ++++++++++++ 4 files changed, 217 insertions(+), 93 deletions(-) create mode 100644 app/test/firehose_web/live/source_viewer_test.exs diff --git a/app/assets/css/app.css b/app/assets/css/app.css index 405cd2b..5425fd3 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -286,124 +286,123 @@ body { font-family: 'Source Sans 3', sans-serif; } } /* highlight.js — Atom One Dark (dark theme) */ -.source-viewer code.hljs { +.source-viewer .hljs { color: #abb2bf; background: transparent; - padding: 0; } -.source-viewer code.hljs .hljs-comment, -.source-viewer code.hljs .hljs-quote { +.source-viewer .hljs .hljs-comment, +.source-viewer .hljs .hljs-quote { color: #5c6370; font-style: italic; } -.source-viewer code.hljs .hljs-doctag, -.source-viewer code.hljs .hljs-keyword, -.source-viewer code.hljs .hljs-formula { +.source-viewer .hljs .hljs-doctag, +.source-viewer .hljs .hljs-keyword, +.source-viewer .hljs .hljs-formula { color: #c678dd; } -.source-viewer code.hljs .hljs-section, -.source-viewer code.hljs .hljs-name, -.source-viewer code.hljs .hljs-selector-tag, -.source-viewer code.hljs .hljs-deletion, -.source-viewer code.hljs .hljs-subst { +.source-viewer .hljs .hljs-section, +.source-viewer .hljs .hljs-name, +.source-viewer .hljs .hljs-selector-tag, +.source-viewer .hljs .hljs-deletion, +.source-viewer .hljs .hljs-subst { color: #e06c75; } -.source-viewer code.hljs .hljs-literal { +.source-viewer .hljs .hljs-literal { color: #56b6c2; } -.source-viewer code.hljs .hljs-string, -.source-viewer code.hljs .hljs-regexp, -.source-viewer code.hljs .hljs-addition, -.source-viewer code.hljs .hljs-attribute, -.source-viewer code.hljs .hljs-meta .hljs-string { +.source-viewer .hljs .hljs-string, +.source-viewer .hljs .hljs-regexp, +.source-viewer .hljs .hljs-addition, +.source-viewer .hljs .hljs-attribute, +.source-viewer .hljs .hljs-meta .hljs-string { color: #98c379; } -.source-viewer code.hljs .hljs-attr, -.source-viewer code.hljs .hljs-variable, -.source-viewer code.hljs .hljs-template-variable, -.source-viewer code.hljs .hljs-type, -.source-viewer code.hljs .hljs-selector-class, -.source-viewer code.hljs .hljs-selector-attr, -.source-viewer code.hljs .hljs-selector-pseudo, -.source-viewer code.hljs .hljs-number { +.source-viewer .hljs .hljs-attr, +.source-viewer .hljs .hljs-variable, +.source-viewer .hljs .hljs-template-variable, +.source-viewer .hljs .hljs-type, +.source-viewer .hljs .hljs-selector-class, +.source-viewer .hljs .hljs-selector-attr, +.source-viewer .hljs .hljs-selector-pseudo, +.source-viewer .hljs .hljs-number { color: #d19a66; } -.source-viewer code.hljs .hljs-symbol, -.source-viewer code.hljs .hljs-link, -.source-viewer code.hljs .hljs-meta, -.source-viewer code.hljs .hljs-selector-id, -.source-viewer code.hljs .hljs-title { +.source-viewer .hljs .hljs-symbol, +.source-viewer .hljs .hljs-link, +.source-viewer .hljs .hljs-meta, +.source-viewer .hljs .hljs-selector-id, +.source-viewer .hljs .hljs-title { color: #61aeee; } -.source-viewer code.hljs .hljs-built_in, -.source-viewer code.hljs .hljs-title.class_, -.source-viewer code.hljs .hljs-class .hljs-title { +.source-viewer .hljs .hljs-built_in, +.source-viewer .hljs .hljs-title.class_, +.source-viewer .hljs .hljs-class .hljs-title { color: #e6c07b; } -.source-viewer code.hljs .hljs-emphasis { +.source-viewer .hljs .hljs-emphasis { font-style: italic; } -.source-viewer code.hljs .hljs-strong { +.source-viewer .hljs .hljs-strong { font-weight: bold; } /* highlight.js — Atom One Light (light theme) */ -[data-theme="light"] .source-viewer code.hljs { +[data-theme="light"] .source-viewer .hljs { color: #383a42; background: transparent; } -[data-theme="light"] .source-viewer code.hljs .hljs-comment, -[data-theme="light"] .source-viewer code.hljs .hljs-quote { +[data-theme="light"] .source-viewer .hljs .hljs-comment, +[data-theme="light"] .source-viewer .hljs .hljs-quote { color: #a0a1a7; font-style: italic; } -[data-theme="light"] .source-viewer code.hljs .hljs-doctag, -[data-theme="light"] .source-viewer code.hljs .hljs-keyword, -[data-theme="light"] .source-viewer code.hljs .hljs-formula { +[data-theme="light"] .source-viewer .hljs .hljs-doctag, +[data-theme="light"] .source-viewer .hljs .hljs-keyword, +[data-theme="light"] .source-viewer .hljs .hljs-formula { color: #a626a4; } -[data-theme="light"] .source-viewer code.hljs .hljs-section, -[data-theme="light"] .source-viewer code.hljs .hljs-name, -[data-theme="light"] .source-viewer code.hljs .hljs-selector-tag, -[data-theme="light"] .source-viewer code.hljs .hljs-deletion, -[data-theme="light"] .source-viewer code.hljs .hljs-subst { +[data-theme="light"] .source-viewer .hljs .hljs-section, +[data-theme="light"] .source-viewer .hljs .hljs-name, +[data-theme="light"] .source-viewer .hljs .hljs-selector-tag, +[data-theme="light"] .source-viewer .hljs .hljs-deletion, +[data-theme="light"] .source-viewer .hljs .hljs-subst { color: #e45649; } -[data-theme="light"] .source-viewer code.hljs .hljs-literal { +[data-theme="light"] .source-viewer .hljs .hljs-literal { color: #0184bc; } -[data-theme="light"] .source-viewer code.hljs .hljs-string, -[data-theme="light"] .source-viewer code.hljs .hljs-regexp, -[data-theme="light"] .source-viewer code.hljs .hljs-addition, -[data-theme="light"] .source-viewer code.hljs .hljs-attribute, -[data-theme="light"] .source-viewer code.hljs .hljs-meta .hljs-string { +[data-theme="light"] .source-viewer .hljs .hljs-string, +[data-theme="light"] .source-viewer .hljs .hljs-regexp, +[data-theme="light"] .source-viewer .hljs .hljs-addition, +[data-theme="light"] .source-viewer .hljs .hljs-attribute, +[data-theme="light"] .source-viewer .hljs .hljs-meta .hljs-string { color: #50a14f; } -[data-theme="light"] .source-viewer code.hljs .hljs-attr, -[data-theme="light"] .source-viewer code.hljs .hljs-variable, -[data-theme="light"] .source-viewer code.hljs .hljs-template-variable, -[data-theme="light"] .source-viewer code.hljs .hljs-type, -[data-theme="light"] .source-viewer code.hljs .hljs-selector-class, -[data-theme="light"] .source-viewer code.hljs .hljs-selector-attr, -[data-theme="light"] .source-viewer code.hljs .hljs-selector-pseudo, -[data-theme="light"] .source-viewer code.hljs .hljs-number { +[data-theme="light"] .source-viewer .hljs .hljs-attr, +[data-theme="light"] .source-viewer .hljs .hljs-variable, +[data-theme="light"] .source-viewer .hljs .hljs-template-variable, +[data-theme="light"] .source-viewer .hljs .hljs-type, +[data-theme="light"] .source-viewer .hljs .hljs-selector-class, +[data-theme="light"] .source-viewer .hljs .hljs-selector-attr, +[data-theme="light"] .source-viewer .hljs .hljs-selector-pseudo, +[data-theme="light"] .source-viewer .hljs .hljs-number { color: #986801; } -[data-theme="light"] .source-viewer code.hljs .hljs-symbol, -[data-theme="light"] .source-viewer code.hljs .hljs-link, -[data-theme="light"] .source-viewer code.hljs .hljs-meta, -[data-theme="light"] .source-viewer code.hljs .hljs-selector-id, -[data-theme="light"] .source-viewer code.hljs .hljs-title { +[data-theme="light"] .source-viewer .hljs .hljs-symbol, +[data-theme="light"] .source-viewer .hljs .hljs-link, +[data-theme="light"] .source-viewer .hljs .hljs-meta, +[data-theme="light"] .source-viewer .hljs .hljs-selector-id, +[data-theme="light"] .source-viewer .hljs .hljs-title { color: #4078f2; } -[data-theme="light"] .source-viewer code.hljs .hljs-built_in, -[data-theme="light"] .source-viewer code.hljs .hljs-title.class_, -[data-theme="light"] .source-viewer code.hljs .hljs-class .hljs-title { +[data-theme="light"] .source-viewer .hljs .hljs-built_in, +[data-theme="light"] .source-viewer .hljs .hljs-title.class_, +[data-theme="light"] .source-viewer .hljs .hljs-class .hljs-title { color: #c18401; } -[data-theme="light"] .source-viewer code.hljs .hljs-emphasis { +[data-theme="light"] .source-viewer .hljs .hljs-emphasis { font-style: italic; } -[data-theme="light"] .source-viewer code.hljs .hljs-strong { +[data-theme="light"] .source-viewer .hljs .hljs-strong { font-weight: bold; } diff --git a/app/assets/js/hooks/source_viewer.js b/app/assets/js/hooks/source_viewer.js index cf426e1..89b2009 100644 --- a/app/assets/js/hooks/source_viewer.js +++ b/app/assets/js/hooks/source_viewer.js @@ -4,7 +4,7 @@ * Attached to the `#source-viewer` div via `phx-hook="SourceViewer"`. * Reads `data-highlighted-line` to find the target line element and * scrolls it into view with a smooth animation. - * Also applies highlight.js syntax highlighting to code blocks. + * Applies highlight.js syntax highlighting to individual line content elements. */ import hljs from "highlight.js/lib/core" @@ -20,10 +20,33 @@ export const SourceViewer = { }, applySyntaxHighlighting() { - const codeEl = this.el.querySelector("code") - if (codeEl && !codeEl.querySelector(".hljs")) { - hljs.highlightElement(codeEl) - } + const container = this.el.querySelector(".sv-lines") + if (!container || container.querySelector(".hljs-on")) return; + + // Collect the raw text from all line content spans + const lineContentEls = container.querySelectorAll(".sv-line-content") + if (lineContentEls.length === 0) return; + + const rawText = Array.from(lineContentEls).map(el => el.textContent).join("\n") + + // Create a temp element for highlight.js to process + const temp = document.createElement("code") + temp.textContent = rawText + hljs.highlightElement(temp) + + // Split the highlighted HTML back into lines + // highlight.js innerHTML uses actual newlines between lines of code + const highlightedLines = temp.innerHTML.split("\n") + + // Apply each highlighted line to the corresponding .sv-line-content + lineContentEls.forEach((el, i) => { + if (highlightedLines[i]) { + el.innerHTML = highlightedLines[i] + } + }) + + // Mark as highlighted so we don't re-apply on subsequent updates + container.classList.add("hljs", "hljs-on") }, scrollToHighlighted() { @@ -35,4 +58,4 @@ export const SourceViewer = { } } } -} +} \ No newline at end of file diff --git a/app/lib/firehose_web/live/microprints_live.ex b/app/lib/firehose_web/live/microprints_live.ex index 6dab3a5..84e95cf 100644 --- a/app/lib/firehose_web/live/microprints_live.ex +++ b/app/lib/firehose_web/live/microprints_live.ex @@ -26,7 +26,7 @@ defmodule FirehoseWeb.MicroprintsLive do |> assign(:page_title, "Microprints") |> assign(:microprints, []) |> assign(:expanded_path, nil) - |> assign(:source_content, nil) + |> assign(:source_lines, nil) |> assign(:highlighted_path, nil) |> assign(:highlighted_line, nil) |> put_flash(:error, "Error loading microprints: #{inspect(e)}")} @@ -80,8 +80,8 @@ defmodule FirehoseWeb.MicroprintsLive do highlighted_line={@highlighted_line} /> - <%= if @expanded_path == path and @source_content do %> <.source_viewer - source_content={@source_content} + <%= if @expanded_path == path and @source_lines do %> <.source_viewer + source_lines={@source_lines} highlighted_line={@highlighted_line} language="elixir" file_path={path} @@ -120,7 +120,7 @@ defmodule FirehoseWeb.MicroprintsLive do |> assign(:highlighted_path, path) |> assign(:highlighted_line, highlighted) |> assign(:expanded_path, expanded) - |> assign(:source_content, nil) + |> assign(:source_lines, nil) {:noreply, push_patch(socket, to: build_params(socket))} end @@ -133,7 +133,7 @@ defmodule FirehoseWeb.MicroprintsLive do _ -> path end - source_content = + source_lines = case expanded do nil -> nil ^path -> @@ -146,7 +146,7 @@ defmodule FirehoseWeb.MicroprintsLive do end end - socket = socket |> assign(:expanded_path, expanded) |> assign(:source_content, source_content) + socket = socket |> assign(:expanded_path, expanded) |> assign(:source_lines, source_lines) {:noreply, push_patch(socket, to: build_params(socket))} end @@ -158,7 +158,7 @@ defmodule FirehoseWeb.MicroprintsLive do highlighted_path = params["highlighted"] highlighted_line = if params["line"], do: String.to_integer(params["line"]), else: nil - source_content = + source_lines = if expanded_path do item = Enum.find(socket.assigns.microprints, &(&1.path == expanded_path)) @@ -170,7 +170,7 @@ defmodule FirehoseWeb.MicroprintsLive do socket |> assign(:expanded_path, expanded_path) - |> assign(:source_content, source_content) + |> assign(:source_lines, source_lines) |> assign(:highlighted_path, highlighted_path) |> assign(:highlighted_line, highlighted_line) end @@ -325,13 +325,8 @@ defmodule FirehoseWeb.MicroprintsLive do defp read_source(abs_path) do case File.read(abs_path) do - {:ok, content} -> - content - |> String.replace("<", "<") - |> String.replace(">", ">") - - {:error, _} -> - nil + {:ok, content} -> String.split(content, "\n") + {:error, _} -> nil end end @@ -353,7 +348,17 @@ defmodule FirehoseWeb.MicroprintsLive do data-language={@language} style="background: var(--sv-bg); color: var(--sv-text);" > -
 "-pre"} phx-update="ignore" class="m-0 p-2"><%= @source_content %>
+
+ <%= for {line, num} <- Enum.with_index(@source_lines || [], 1) do %> +
if @highlighted_line == num, do: " sv-line-highlighted", else: ""} + > + <%= num %> + <%= line %> +
+ <% end %> +
""" end diff --git a/app/test/firehose_web/live/source_viewer_test.exs b/app/test/firehose_web/live/source_viewer_test.exs new file mode 100644 index 0000000..98004e1 --- /dev/null +++ b/app/test/firehose_web/live/source_viewer_test.exs @@ -0,0 +1,97 @@ +defmodule FirehoseWeb.SourceViewerTest do + use FirehoseWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias FirehoseWeb.MicroprintsLive + + defp expand_file(view, path) do + view + |> element("button[phx-value-path=\"#{path}\"]", "Expand") + |> render_click() + end + + defp click_microprint_rect(view, path, line) do + render_click(view, "highlight_line", %{"line" => Integer.to_string(line), "path" => path}) + end + + describe "line elements" do + test "render with unique ids when file is expanded", %{conn: conn} do + files = MicroprintsLive.scan_source_files() + file = List.first(files) + + {:ok, view, _html} = live(conn, ~p"/microprints") + expand_file(view, file) + + assert has_element?(view, "#line-1"), + "Line 1 should exist in the source viewer" + assert has_element?(view, "#line-2"), + "Line 2 should exist in the source viewer" + assert has_element?(view, "#line-3"), + "Line 3 should exist in the source viewer" + end + end + + describe "highlight class" do + test "is added to line on microprint click", %{conn: conn} do + files = MicroprintsLive.scan_source_files() + file = List.first(files) + + {:ok, view, _html} = live(conn, ~p"/microprints") + expand_file(view, file) + + click_microprint_rect(view, file, 1) + + assert has_element?(view, "#line-1.sv-line-highlighted"), + "Line 1 should have the highlighted class after click" + end + + test "is removed on re-clicking the same line", %{conn: conn} do + files = MicroprintsLive.scan_source_files() + file = List.first(files) + + {:ok, view, _html} = live(conn, ~p"/microprints") + expand_file(view, file) + + click_microprint_rect(view, file, 1) + assert has_element?(view, "#line-1.sv-line-highlighted"), + "Line 1 should be highlighted after first click" + + click_microprint_rect(view, file, 1) + refute has_element?(view, "#line-1.sv-line-highlighted"), + "Line 1 should no longer be highlighted after second click" + end + + test "persists when expand is restored from URL params", %{conn: conn} do + files = MicroprintsLive.scan_source_files() + file = List.first(files) + + {:ok, view, _html} = live(conn, ~p"/microprints?expanded=#{file}&line=1&highlighted=#{file}") + + assert has_element?(view, "#line-1.sv-line-highlighted"), + "Line 1 should be highlighted when restored from URL params" + end + end + + describe "different file highlighting" do + test "collapses file A and removes its source viewer when highlighting file B", %{conn: conn} do + files = MicroprintsLive.scan_source_files() + assert length(files) >= 2, "Need at least 2 files to test different-file highlighting" + + [file_a, file_b | _] = files + + {:ok, view, _html} = live(conn, ~p"/microprints") + + expand_file(view, file_a) + click_microprint_rect(view, file_a, 1) + + assert has_element?(view, "#line-1.sv-line-highlighted"), + "Line 1 in file A should be highlighted before switching" + + click_microprint_rect(view, file_b, 2) + + refute has_element?(view, "#line-1"), + "File A source viewer should be gone after highlighting a different file" + end + end +end