Module: Plumbo::Panel

Defined in:
lib/plumbo/panel.rb

Overview

Builds the self-contained panel markup injected into the page: a scoped <style> block, inline SVG icons, server-rendered file rows, and a vanilla-JS <script>. Everything is namespaced under #plumbo so it can’t clash with or leak into the host app’s CSS/JS. (Port of the original Tailwind partial + Stimulus controller.)

Constant Summary collapse

ICONS =

Inline SVGs from Lucide (lucide.dev), ISC-licensed. UI icons (:file, :x, :copy) plus per-type row icons keyed by the symbol #category returns. :file doubles as the catch-all row icon.

{
  file: <<~SVG,
  x: <<~SVG,
  copy: <<~SVG,
  controller: <<~SVG,
  helper: <<~SVG,
  layout: <<~SVG,
  partial: <<~SVG,
  view: <<~SVG,
  ruby: <<~SVG,
  javascript: <<~SVG,
  copy_all: <<~SVG,
  clear: <<~SVG,
  check: <<~SVG,
  chevron: <<~SVG
    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
  SVG
}.freeze
CATEGORY_RULES =

Path patterns mapped to icon keys, matched in order. Partials are listed before layouts so a ‘_partial` living in app/views/layouts still reads as a partial. The first match wins; #category falls back to :file.

[
  [%r{/controllers/.*\.rb\z}, :controller],
  [%r{/helpers/.*\.rb\z}, :helper],
  [%r{/_[^/]+\.erb\z}, :partial],
  [%r{/layouts/}, :layout],
  [/\.erb\z/, :view],
  [/\.js\z/, :javascript],
  [/\.rb\z/, :ruby]
].freeze
CSS =
<<~CSS
  #plumbo{position:fixed;z-index:2147483000;bottom:1rem;right:1rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.4;text-align:left}
  #plumbo *{box-sizing:border-box}
  #plumbo .plumbo-toggle{display:flex;align-items:center;gap:6px;background:#111827;color:#d1d5db;border:1px solid rgba(255,255,255,.1);border-radius:9999px;padding:8px 12px;cursor:pointer;box-shadow:0 10px 15px -3px rgba(0,0,0,.35)}
  #plumbo .plumbo-toggle:hover{background:#1f2937;color:#fff}
  #plumbo .plumbo-panel{position:absolute;bottom:3rem;right:0;width:34rem;max-width:90vw;height:70vh;max-height:90vh;background:#111827;color:#d1d5db;border:1px solid rgba(255,255,255,.1);border-radius:8px;box-shadow:0 25px 50px -12px rgba(0,0,0,.5);display:flex;flex-direction:column;overflow:hidden}
  #plumbo .plumbo-panel[hidden]{display:none}
  #plumbo .plumbo-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid rgba(255,255,255,.1)}
  #plumbo .plumbo-title{font-weight:600;color:#fff;font-size:13px}
  #plumbo .plumbo-actions{display:flex;align-items:center;gap:8px}
  #plumbo button{font:inherit;cursor:pointer;background:transparent;border:0;color:inherit;margin:0}
  #plumbo .plumbo-action{display:flex;align-items:center;color:#9ca3af;padding:4px;border-radius:4px}
  #plumbo .plumbo-action:hover{background:rgba(255,255,255,.1);color:#fff}
  #plumbo .plumbo-close{color:#6b7280;display:flex;padding:2px}
  #plumbo .plumbo-close:hover{color:#fff}
  #plumbo .plumbo-filterbar{display:flex;flex-direction:column;gap:8px;padding:10px 16px;border-bottom:1px solid rgba(255,255,255,.1)}
  #plumbo .plumbo-filter{width:100%;font:inherit;color:#e5e7eb;background:#0b1220;border:1px solid rgba(255,255,255,.1);border-radius:6px;padding:6px 10px}
  #plumbo .plumbo-filter::placeholder{color:#6b7280}
  #plumbo .plumbo-filter:focus{outline:none;border-color:#3b82f6}
  #plumbo .plumbo-chips{display:flex;flex-wrap:nowrap;gap:5px;overflow-x:auto;scrollbar-width:none}
  #plumbo .plumbo-chips::-webkit-scrollbar{display:none}
  #plumbo .plumbo-chip{flex:none;white-space:nowrap;color:#9ca3af;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-radius:9999px;padding:2px 8px;font-size:10px}
  #plumbo .plumbo-chip:hover{background:rgba(255,255,255,.1);color:#fff}
  #plumbo .plumbo-chip[aria-pressed="true"]{background:#2563eb;border-color:#2563eb;color:#fff}
  #plumbo .plumbo-list{flex:1;min-height:0;list-style:none;margin:0;padding:0;overflow-y:auto;background:#0b1220}
  #plumbo .plumbo-row{--d:0;display:flex;align-items:center;gap:8px;width:100%;padding:7px 16px;padding-left:calc(16px + var(--d) * 14px);color:#d1d5db;text-align:left;cursor:default;background-image:repeating-linear-gradient(to right,rgba(255,255,255,.09) 0,rgba(255,255,255,.09) 1px,transparent 1px,transparent 14px);background-repeat:no-repeat;background-position:20px 0;background-size:calc(var(--d) * 14px) 100%}
  #plumbo .plumbo-row:hover{background-color:rgba(255,255,255,.05);color:#fff}
  #plumbo .plumbo-row.plumbo-parent{cursor:pointer}
  #plumbo .plumbo-caret{flex:none;width:12px;display:flex;color:#6b7280}
  #plumbo .plumbo-caret svg{visibility:hidden;transition:transform .12s}
  #plumbo .plumbo-row.plumbo-parent .plumbo-caret svg{visibility:visible;transform:rotate(90deg)}
  #plumbo .plumbo-row.plumbo-parent.plumbo-collapsed .plumbo-caret svg{transform:rotate(0)}
  #plumbo .plumbo-row.plumbo-parent:hover .plumbo-caret{color:#fff}
  #plumbo .plumbo-type{flex:none;display:flex;color:#6b7280}
  #plumbo .plumbo-row:hover .plumbo-type{color:#9ca3af}
  #plumbo .plumbo-path{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  #plumbo .plumbo-copy{margin-left:auto;flex:none;color:#4b5563;display:flex;cursor:pointer}
  #plumbo .plumbo-row:hover .plumbo-copy{color:#9ca3af}
  #plumbo .plumbo-row[data-depth="1"] .plumbo-path{color:#93c5fd}
  #plumbo .plumbo-row[data-depth="1"] .plumbo-type{color:#60a5fa}
  #plumbo .plumbo-row[data-depth="2"] .plumbo-path{color:#c4b5fd}
  #plumbo .plumbo-row[data-depth="2"] .plumbo-type{color:#a78bfa}
  #plumbo .plumbo-row[data-depth="3"] .plumbo-path{color:#6ee7b7}
  #plumbo .plumbo-row[data-depth="3"] .plumbo-type{color:#34d399}
  #plumbo .plumbo-row[data-depth="4"] .plumbo-path{color:#fcd34d}
  #plumbo .plumbo-row[data-depth="4"] .plumbo-type{color:#fbbf24}
  #plumbo .plumbo-row[data-depth="5"] .plumbo-path{color:#f9a8d4}
  #plumbo .plumbo-row[data-depth="5"] .plumbo-type{color:#f472b6}
  #plumbo .plumbo-flash{color:#4ade80}
  #plumbo svg{display:block}
CSS
JS =
<<~JS.freeze
  (function(){
    if (window.__plumboBound) return;
    window.__plumboBound = true;
    var query = "";
    var activeCategory = null;
    var LABELS = { controller:"Controllers", helper:"Helpers", layout:"Layouts", partial:"Partials", view:"Views", javascript:"JavaScript", ruby:"Ruby", file:"Other" };
    var CHECK = '#{ICONS[:check].strip}';
    function copy(text){ if (navigator.clipboard) { navigator.clipboard.writeText(text); } }
    // Briefly swap an icon element's contents for a green check as feedback,
    // leaving the surrounding row (filename, etc.) untouched.
    function flashIcon(el){
      if (!el) return;
      var original = el.innerHTML;
      el.innerHTML = CHECK;
      el.classList.add("plumbo-flash");
      setTimeout(function(){ el.innerHTML = original; el.classList.remove("plumbo-flash"); }, 1200);
    }
    document.addEventListener("click", function(event){
      var root = document.getElementById("plumbo");
      if (!root) return;
      var hit = event.target.closest("[data-plumbo-toggle],[data-plumbo-close],[data-plumbo-collapse],[data-plumbo-copy],[data-plumbo-copy-all],[data-plumbo-clear],[data-plumbo-chip]");
      if (!hit || !root.contains(hit)) return;
      var panel = root.querySelector("[data-plumbo-panel]");
      if (hit.hasAttribute("data-plumbo-toggle")) { panel.hidden = !panel.hidden; }
      else if (hit.hasAttribute("data-plumbo-close")) { panel.hidden = true; }
      else if (hit.hasAttribute("data-plumbo-collapse")) {
        var parentRow = hit.closest(".plumbo-row");
        if (parentRow && parentRow.classList.contains("plumbo-parent")) {
          parentRow.classList.toggle("plumbo-collapsed"); applyFilter();
        }
      }
      else if (hit.hasAttribute("data-plumbo-copy")) {
        var copyRow = hit.closest(".plumbo-row");
        if (copyRow) { copy(copyRow.getAttribute("data-path")); flashIcon(hit); }
      }
      else if (hit.hasAttribute("data-plumbo-copy-all")) {
        copy(visiblePaths(root).join("\\n")); flashIcon(hit);
      }
      else if (hit.hasAttribute("data-plumbo-clear")) {
        var emptied = root.querySelector("#plumbo-list");
        if (emptied) { emptied.innerHTML = ""; refresh(); }
      }
      else if (hit.hasAttribute("data-plumbo-chip")) {
        var cat = hit.getAttribute("data-category");
        activeCategory = !cat ? null : (activeCategory === cat ? null : cat);
        refresh();
      }
    });
    document.addEventListener("input", function(event){
      var root = document.getElementById("plumbo");
      var el = event.target;
      if (!root || !el.hasAttribute || !el.hasAttribute("data-plumbo-filter") || !root.contains(el)) return;
      query = el.value || "";
      applyFilter();
    });
    // The data-paths of rows currently visible (after filtering), for Copy All.
    function visiblePaths(root){
      var paths = [];
      var rows = root.querySelectorAll("#plumbo-list > li");
      for (var i = 0; i < rows.length; i++){
        if (rows[i].hidden) continue;
        var button = rows[i].querySelector("[data-path]");
        if (button) paths.push(button.getAttribute("data-path"));
      }
      return paths;
    }
    function depthOf(button){ return parseInt(button.getAttribute("data-depth") || "0", 10); }
    // Flag rows whose next row is deeper as collapsible parents, and enable
    // their caret; clear the flag (and any collapse) on rows without children.
    function markParents(){
      var root = document.getElementById("plumbo");
      if (!root) return;
      var rows = root.querySelectorAll("#plumbo-list .plumbo-row");
      for (var i = 0; i < rows.length; i++){
        var next = rows[i + 1];
        var hasChildren = next && depthOf(next) > depthOf(rows[i]);
        if (hasChildren) { rows[i].classList.add("plumbo-parent"); }
        else { rows[i].classList.remove("plumbo-parent", "plumbo-collapsed"); }
      }
    }
    // Show only rows matching the text query AND the active type chip. While
    // not filtering, also hide rows nested under a collapsed parent.
    function applyFilter(){
      var root = document.getElementById("plumbo");
      if (!root) return;
      var q = query.toLowerCase();
      var filtering = q !== "" || activeCategory !== null;
      var rows = root.querySelectorAll("#plumbo-list > li");
      var hideBelow = Infinity;
      for (var i = 0; i < rows.length; i++){
        var button = rows[i].querySelector("[data-path]");
        var depth = button ? depthOf(button) : 0;
        var collapsed = false;
        if (!filtering){
          if (depth > hideBelow) { collapsed = true; }
          else { hideBelow = (button && button.classList.contains("plumbo-collapsed")) ? depth : Infinity; }
        }
        var path = button ? button.getAttribute("data-path").toLowerCase() : "";
        var cat = button ? button.getAttribute("data-category") : "";
        var matches = (!q || path.indexOf(q) !== -1) && (!activeCategory || cat === activeCategory);
        rows[i].hidden = collapsed || !matches;
      }
    }
    // Rebuild the type chips (counts) from the rows currently in the list.
    function buildChips(){
      var root = document.getElementById("plumbo");
      if (!root) return;
      var container = root.querySelector("[data-plumbo-chips]");
      if (!container) return;
      var buttons = root.querySelectorAll("#plumbo-list [data-category]");
      var order = [], counts = {};
      for (var i = 0; i < buttons.length; i++){
        var cat = buttons[i].getAttribute("data-category");
        if (counts[cat] === undefined){ counts[cat] = 0; order.push(cat); }
        counts[cat]++;
      }
      var html = chip(null, "All", buttons.length);
      for (var j = 0; j < order.length; j++){ html += chip(order[j], LABELS[order[j]] || order[j], counts[order[j]]); }
      container.innerHTML = html;
    }
    function chip(cat, label, count){
      var pressed = (activeCategory === cat) ? "true" : "false";
      var attr = (cat === null) ? "" : (' data-category="' + cat + '"');
      return '<button type="button" class="plumbo-chip" data-plumbo-chip' + attr + ' aria-pressed="' + pressed + '">' + label + ' ' + count + '</button>';
    }
    function refresh(){ markParents(); buildChips(); applyFilter(); updateCount(); }
    // Sync the count badges to the current number of rows.
    function updateCount(){
      var total = document.querySelectorAll("#plumbo-list .plumbo-row").length;
      var counts = document.querySelectorAll("#plumbo .plumbo-count");
      for (var j = 0; j < counts.length; j++){ counts[j].textContent = total; }
    }
    var ICONS = {#{ICONS.map { |key, svg| "#{key}:'#{svg.strip}'" }.join(',')}};
    function escapeHtml(s){ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
    // Build a file row from [path, depth, category], mirroring the server.
    function buildRow(path, depth, category){
      var safe = escapeHtml(path);
      var icon = ICONS[category] || ICONS.file;
      return '<li><button type="button" class="plumbo-row" data-plumbo-collapse data-path="' + safe + '" data-category="' + escapeHtml(category) + '" data-depth="' + depth + '" style="--d:' + depth + '">'
        + '<span class="plumbo-caret">' + ICONS.chevron + '</span>'
        + '<span class="plumbo-type">' + icon + '</span>'
        + '<span class="plumbo-path">' + safe + '</span>'
        + '<span class="plumbo-copy" data-plumbo-copy title="Copy path">' + ICONS.copy + '</span>'
        + '</button></li>';
    }
    // Merge the files from an X-Plumbo-Files header into the panel, skipping
    // paths already listed, then renumber and rebuild chips/filter.
    function mergeFiles(encoded){
      var root = document.getElementById("plumbo");
      if (!root) return;
      var list = root.querySelector("#plumbo-list");
      if (!list) return;
      var data;
      try { data = JSON.parse(atob(encoded)); } catch (e) { return; }
      var seen = {};
      var existing = list.querySelectorAll("[data-path]");
      for (var i = 0; i < existing.length; i++){ seen[existing[i].getAttribute("data-path")] = true; }
      var html = "";
      for (var j = 0; j < data.length; j++){
        var path = data[j][0];
        if (seen[path]) continue;
        seen[path] = true;
        html += buildRow(path, data[j][1], data[j][2]);
      }
      if (html) { list.insertAdjacentHTML("beforeend", html); }
      refresh();
    }
    refresh();
    // Read the file list off every fetch response (Turbo Drive/Frame/Stream
    // and custom fetch all go through fetch) so the panel keeps up without a
    // full reload. Reading a header doesn't consume the response body.
    if (window.fetch){
      var plumboFetch = window.fetch;
      window.fetch = function(){
        return plumboFetch.apply(this, arguments).then(function(response){
          try { var data = response.headers.get("X-Plumbo-Files"); if (data) mergeFiles(data); } catch (e) {}
          return response;
        });
      };
    }
    // A full Turbo Drive visit swaps in a fresh panel; reset the filter to
    // match the new page, then rebuild.
    document.addEventListener("turbo:load", function(){ query = ""; activeCategory = null; refresh(); });
  })();
JS

Class Method Summary collapse

Class Method Details

.category(path) ⇒ Object

Classifies a file by its path so each row can show a matching icon, returning the icon key of the first matching rule or :file otherwise.



391
392
393
394
# File 'lib/plumbo/panel.rb', line 391

def category(path)
  rule = CATEGORY_RULES.find { |pattern, _type| path.match?(pattern) }
  rule ? rule.last : :file
end

.file_icon(path) ⇒ Object

Looks up the type icon for a path, falling back to the generic file icon.



385
386
387
# File 'lib/plumbo/panel.rb', line 385

def file_icon(path)
  ICONS.fetch(category(path), ICONS[:file])
end

.header(count) ⇒ Object

The panel’s title bar, carrying the count and the copy-all/close controls.



346
347
348
349
350
351
352
353
354
355
356
357
# File 'lib/plumbo/panel.rb', line 346

def header(count)
  <<~HTML
    <div class="plumbo-header">
      <span class="plumbo-title">Plumbo (<span class="plumbo-count">#{count}</span>)</span>
      <div class="plumbo-actions">
        <button type="button" class="plumbo-action" data-plumbo-copy-all title="Copy all paths">#{ICONS[:copy_all]}</button>
        <button type="button" class="plumbo-action" data-plumbo-clear title="Clear the list to watch new files as you navigate">#{ICONS[:clear]}</button>
        <button type="button" class="plumbo-close" data-plumbo-close title="Close">#{ICONS[:x]}</button>
      </div>
    </div>
  HTML
end

.render(files) ⇒ Object

Returns the full panel HTML for the given list of file paths.



315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# File 'lib/plumbo/panel.rb', line 315

def render(files)
  count = files.size

  <<~HTML
    <div id="plumbo">
      <style>#{CSS}</style>
      #{toggle(count)}
      <div class="plumbo-panel" hidden data-plumbo-panel>
        #{header(count)}
        <div class="plumbo-filterbar">
          <input type="text" class="plumbo-filter" data-plumbo-filter placeholder="Filter files…" aria-label="Filter files">
          <div class="plumbo-chips" data-plumbo-chips></div>
        </div>
        <ol id="plumbo-list" class="plumbo-list" data-plumbo-list>#{rows(files)}</ol>
      </div>
      <script>#{JS}</script>
    </div>
  HTML
end

.row(path, depth = 0) ⇒ Object

Renders a single file row: clicking it collapses/expands (when it has children), clicking the copy icon copies the path. Tagged with its category (for filtering) and depth (indent guide line + per-level color).



372
373
374
375
376
377
378
379
380
381
382
# File 'lib/plumbo/panel.rb', line 372

def row(path, depth = 0)
  safe = ERB::Util.html_escape(path)
  <<~HTML
    <li><button type="button" class="plumbo-row" data-plumbo-collapse data-path="#{safe}" data-category="#{category(path)}" data-depth="#{depth}" style="--d:#{depth}">
      <span class="plumbo-caret">#{ICONS[:chevron]}</span>
      <span class="plumbo-type">#{file_icon(path)}</span>
      <span class="plumbo-path">#{safe}</span>
      <span class="plumbo-copy" data-plumbo-copy title="Copy path">#{ICONS[:copy]}</span>
    </button></li>
  HTML
end

.rows(files) ⇒ Object

Builds the <li> rows for a list of files, in render order. Each entry is either a bare path or a [path, depth] pair; depth indents the row (with a guide line) to show the parent/child render nesting.



362
363
364
365
366
367
# File 'lib/plumbo/panel.rb', line 362

def rows(files)
  files.map do |entry|
    path, depth = Array(entry)
    row(path, depth || 0)
  end.join
end

.toggle(count) ⇒ Object

The always-visible pill that opens the panel and shows how many files rendered the current page.



337
338
339
340
341
342
343
# File 'lib/plumbo/panel.rb', line 337

def toggle(count)
  <<~HTML
    <button type="button" class="plumbo-toggle" data-plumbo-toggle title="#{count} files used to render this page">
      #{ICONS[:file]}<span class="plumbo-count">#{count}</span>
    </button>
  HTML
end