crates/cli/ents-web/src/assets/ents.js
ents.jshistorycomment on this file
| 1 | /* |
| 2 | * The shell's `⌘K` shortcut: focus the top bar's `.palette` search input |
| 3 | * (the `kbd` hint every page renders beside it -- `crate::pages`'s |
| 4 | * `layout_shell`). Cmd+K on macOS, Ctrl+K elsewhere; Escape hands focus |
| 5 | * back. Enhancement only: the form is a plain GET to `/search` with or |
| 6 | * without this handler. |
| 7 | */ |
| 8 | (function () { |
| 9 | "use strict"; |
| 10 | |
| 11 | var input = document.querySelector('.palette input[name="q"]'); |
| 12 | if (!input) { |
| 13 | return; |
| 14 | } |
| 15 | document.addEventListener("keydown", function (event) { |
| 16 | if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { |
| 17 | event.preventDefault(); |
| 18 | input.focus(); |
| 19 | input.select(); |
| 20 | } else if (event.key === "Escape" && document.activeElement === input) { |
| 21 | input.blur(); |
| 22 | } |
| 23 | }); |
| 24 | })(); |
| 25 | |
| 26 | /* |
| 27 | * Progressive enhancement for `crate::pages::files`'s raw-source blob view |
| 28 | * (`div.blob[data-path][data-rev]`): click a gutter line number to select |
| 29 | * it, shift-click to extend the selection to a range, and open an inline |
| 30 | * comment composer cloned from the server-rendered |
| 31 | * `<template id="composer-template">`. Every behavior here layers on top |
| 32 | * of markup that already works with no script at all -- the plain `#L<n>` |
| 33 | * anchors and the header's "comment on this file" link -- so a disabled or |
| 34 | * failed script load never breaks the page, only the shortcut. |
| 35 | * |
| 36 | * Vanilla, dependency-free: no fetch/AJAX anywhere in this file. The |
| 37 | * composer's own submit is left as an ordinary form POST; only its Cancel |
| 38 | * button and the gutter's "+" affordance are wired up here. |
| 39 | */ |
| 40 | (function () { |
| 41 | "use strict"; |
| 42 | |
| 43 | var blob = document.querySelector("div.blob[data-path][data-rev]"); |
| 44 | if (!blob) { |
| 45 | return; |
| 46 | } |
| 47 | var table = blob.querySelector("table"); |
| 48 | if (!table) { |
| 49 | return; |
| 50 | } |
| 51 | |
| 52 | var rows = Array.prototype.slice.call(table.querySelectorAll("tbody > tr")); |
| 53 | |
| 54 | function lineNumber(tr) { |
| 55 | var a = tr.querySelector("td.blob-nums a"); |
| 56 | return a ? parseInt(a.textContent, 10) : null; |
| 57 | } |
| 58 | |
| 59 | var lineRows = rows.filter(function (tr) { |
| 60 | return lineNumber(tr) !== null; |
| 61 | }); |
| 62 | var byNumber = {}; |
| 63 | lineRows.forEach(function (tr) { |
| 64 | byNumber[lineNumber(tr)] = tr; |
| 65 | }); |
| 66 | |
| 67 | var anchorLine = null; |
| 68 | |
| 69 | // The blob header's open-in-editor deep link follows the selection: |
| 70 | // `data-editor-base` carries the line-less URL, the selected line is |
| 71 | // appended in the editors' shared `:{line}` suffix shape. |
| 72 | var editorLink = blob.querySelector("a.editor-open[data-editor-base]"); |
| 73 | function retargetEditor(line) { |
| 74 | if (editorLink) { |
| 75 | editorLink.href = editorLink.getAttribute("data-editor-base") + ":" + line; |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | function selectRange(start, end) { |
| 80 | retargetEditor(Math.min(start, end)); |
| 81 | var lo = Math.min(start, end); |
| 82 | var hi = Math.max(start, end); |
| 83 | lineRows.forEach(function (tr) { |
| 84 | tr.classList.remove("sel"); |
| 85 | }); |
| 86 | for (var n = lo; n <= hi; n += 1) { |
| 87 | if (byNumber[n]) { |
| 88 | byNumber[n].classList.add("sel"); |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | function setHash(start, end) { |
| 94 | var hash = start === end ? "#L" + start : "#L" + start + "-L" + end; |
| 95 | history.replaceState(null, "", hash); |
| 96 | } |
| 97 | |
| 98 | function applyHash(hash, scroll) { |
| 99 | var match = /^#L(\d+)(?:-L(\d+))?$/.exec(hash); |
| 100 | if (!match) { |
| 101 | return; |
| 102 | } |
| 103 | var start = parseInt(match[1], 10); |
| 104 | var end = match[2] ? parseInt(match[2], 10) : start; |
| 105 | anchorLine = start; |
| 106 | selectRange(start, end); |
| 107 | if (scroll && byNumber[start] && byNumber[start].scrollIntoView) { |
| 108 | byNumber[start].scrollIntoView({ block: "center" }); |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | if (location.hash) { |
| 113 | applyHash(location.hash, true); |
| 114 | } |
| 115 | |
| 116 | table.querySelectorAll("td.blob-nums a").forEach(function (a) { |
| 117 | a.addEventListener("click", function (event) { |
| 118 | event.preventDefault(); |
| 119 | var n = lineNumber(a.closest("tr")); |
| 120 | if (n === null) { |
| 121 | return; |
| 122 | } |
| 123 | if (event.shiftKey && anchorLine !== null) { |
| 124 | selectRange(anchorLine, n); |
| 125 | setHash(Math.min(anchorLine, n), Math.max(anchorLine, n)); |
| 126 | } else { |
| 127 | anchorLine = n; |
| 128 | selectRange(n, n); |
| 129 | setHash(n, n); |
| 130 | } |
| 131 | }); |
| 132 | }); |
| 133 | |
| 134 | function selectedRange() { |
| 135 | var numbers = lineRows |
| 136 | .filter(function (tr) { |
| 137 | return tr.classList.contains("sel"); |
| 138 | }) |
| 139 | .map(lineNumber); |
| 140 | if (numbers.length === 0) { |
| 141 | return null; |
| 142 | } |
| 143 | return [Math.min.apply(null, numbers), Math.max.apply(null, numbers)]; |
| 144 | } |
| 145 | |
| 146 | function openComposer() { |
| 147 | var range = selectedRange(); |
| 148 | var template = document.getElementById("composer-template"); |
| 149 | if (!range || !template) { |
| 150 | return; |
| 151 | } |
| 152 | var existing = table.querySelector("tr.blob-composer"); |
| 153 | if (existing) { |
| 154 | existing.remove(); |
| 155 | } |
| 156 | |
| 157 | // Land below the last selected line's own row, and below any comment |
| 158 | // cards the server already interleaved after it. |
| 159 | var afterRow = byNumber[range[1]]; |
| 160 | if (!afterRow) { |
| 161 | return; |
| 162 | } |
| 163 | while ( |
| 164 | afterRow.nextElementSibling && |
| 165 | afterRow.nextElementSibling.classList.contains("blob-comment-row") |
| 166 | ) { |
| 167 | afterRow = afterRow.nextElementSibling; |
| 168 | } |
| 169 | |
| 170 | var tr = document.createElement("tr"); |
| 171 | tr.className = "blob-composer"; |
| 172 | var td = document.createElement("td"); |
| 173 | td.colSpan = 2; |
| 174 | |
| 175 | var fragment = template.content.cloneNode(true); |
| 176 | var form = fragment.querySelector("form"); |
| 177 | var linesInput = form && form.querySelector('input[name="lines"]'); |
| 178 | if (linesInput) { |
| 179 | linesInput.value = |
| 180 | range[0] === range[1] ? String(range[0]) : range[0] + ":" + range[1]; |
| 181 | } |
| 182 | var cancel = fragment.querySelector(".composer-cancel"); |
| 183 | if (cancel) { |
| 184 | cancel.addEventListener("click", function () { |
| 185 | tr.remove(); |
| 186 | }); |
| 187 | } |
| 188 | |
| 189 | td.appendChild(fragment); |
| 190 | tr.appendChild(td); |
| 191 | afterRow.parentNode.insertBefore(tr, afterRow.nextElementSibling); |
| 192 | } |
| 193 | |
| 194 | // One "+" affordance per line row, injected once -- CSS reveals it on |
| 195 | // row hover (`.blob tr:hover .blob-add`), so there is nothing to |
| 196 | // rebuild on each click. |
| 197 | lineRows.forEach(function (tr) { |
| 198 | var cell = tr.querySelector("td.blob-nums"); |
| 199 | if (!cell) { |
| 200 | return; |
| 201 | } |
| 202 | var button = document.createElement("button"); |
| 203 | button.type = "button"; |
| 204 | button.className = "blob-add"; |
| 205 | button.setAttribute("aria-label", "Comment on this line"); |
| 206 | button.textContent = "+"; |
| 207 | button.addEventListener("click", function (event) { |
| 208 | event.preventDefault(); |
| 209 | var n = lineNumber(tr); |
| 210 | if (n === null) { |
| 211 | return; |
| 212 | } |
| 213 | if (anchorLine === null || !tr.classList.contains("sel")) { |
| 214 | anchorLine = n; |
| 215 | selectRange(n, n); |
| 216 | setHash(n, n); |
| 217 | } |
| 218 | openComposer(); |
| 219 | }); |
| 220 | cell.appendChild(button); |
| 221 | }); |
| 222 | })(); |
| 223 | |
| 224 | /* |
| 225 | * Standalone comment triggers -- "comment on this file" |
| 226 | * (`crate::pages::files::blob_header`) and "comment on this commit" |
| 227 | * (`crate::pages::commits::commit_comment_template`) -- toggle a |
| 228 | * server-rendered `<template>` as a floating popup right under their own |
| 229 | * header/meta row, instead of navigating to a full add-comment page. |
| 230 | * Each trigger's `href` stays a real no-JS fallback. |
| 231 | */ |
| 232 | (function () { |
| 233 | "use strict"; |
| 234 | |
| 235 | document.querySelectorAll("a.composer-trigger[data-composer]").forEach(function (trigger) { |
| 236 | var template = document.getElementById(trigger.getAttribute("data-composer")); |
| 237 | var host = trigger.closest(".blob-header, .commit-meta"); |
| 238 | if (!template || !host) { |
| 239 | return; |
| 240 | } |
| 241 | trigger.addEventListener("click", function (event) { |
| 242 | event.preventDefault(); |
| 243 | var existing = host.querySelector(".standalone-composer"); |
| 244 | if (existing) { |
| 245 | existing.remove(); |
| 246 | return; |
| 247 | } |
| 248 | var wrapper = document.createElement("div"); |
| 249 | wrapper.className = "standalone-composer"; |
| 250 | wrapper.appendChild(template.content.cloneNode(true)); |
| 251 | var cancel = wrapper.querySelector(".composer-cancel"); |
| 252 | if (cancel) { |
| 253 | cancel.addEventListener("click", function () { |
| 254 | wrapper.remove(); |
| 255 | }); |
| 256 | } |
| 257 | host.appendChild(wrapper); |
| 258 | var textarea = wrapper.querySelector("textarea"); |
| 259 | if (textarea) { |
| 260 | textarea.focus(); |
| 261 | } |
| 262 | }); |
| 263 | }); |
| 264 | })(); |