git-ents.gitmain
⌘K
foforge
ents.js264 lines · 7.9 KB · javascripthistorycomment 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})();