onlylogs 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +251 -77
- data/app/assets/images/onlylogs/favicon/apple-touch-icon.png +0 -0
- data/app/assets/images/onlylogs/favicon/favicon-96x96.png +0 -0
- data/app/assets/images/onlylogs/favicon/favicon.ico +0 -0
- data/app/assets/images/onlylogs/favicon/favicon.svg +1 -3
- data/app/assets/images/onlylogs/favicon/web-app-manifest-192x192.png +0 -0
- data/app/assets/images/onlylogs/favicon/web-app-manifest-512x512.png +0 -0
- data/app/assets/images/onlylogs/logo.png +0 -0
- data/app/assets/javascripts/onlylogs/clusterize.js +26 -1
- data/app/channels/onlylogs/logs_channel.rb +14 -9
- data/app/controllers/concerns/onlylogs/authentication.rb +26 -0
- data/app/controllers/onlylogs/application_controller.rb +1 -18
- data/app/controllers/onlylogs/queries_controller.rb +70 -0
- data/app/controllers/onlylogs/viewer_controller.rb +7 -0
- data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +131 -21
- data/app/javascript/onlylogs/controllers/log_cursor_controller.js +286 -0
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +42 -2
- data/app/javascript/onlylogs/controllers/match_highlight_controller.js +82 -0
- data/app/javascript/onlylogs/controllers/queries_controller.js +470 -0
- data/app/javascript/onlylogs/controllers/range_slider_controller.js +5 -4
- data/app/javascript/onlylogs/controllers/text_selection_controller.js +136 -15
- data/app/models/onlylogs/file.rb +14 -5
- data/app/models/onlylogs/grep.rb +13 -6
- data/app/models/onlylogs/queries_record.rb +28 -0
- data/app/models/onlylogs/query.rb +29 -0
- data/app/views/onlylogs/logs/index.html.erb +1 -1
- data/app/views/onlylogs/shared/_log_container.html.erb +165 -27
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +729 -44
- data/app/views/onlylogs/shared/_shortcuts_dialog.html.erb +62 -0
- data/bin/super_grep +7 -0
- data/bin/super_ripgrep +5 -0
- data/config/routes.rb +1 -0
- data/lib/onlylogs/configuration.rb +14 -1
- data/lib/onlylogs/http_device.rb +28 -13
- data/lib/onlylogs/version.rb +1 -1
- data/lib/onlylogs.rb +4 -0
- metadata +38 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// A keyboard cursor over the log lines. Clusterize only keeps a window of the rows
|
|
4
|
+
// in the DOM, so the cursor is an index into all rows, repainted whenever the
|
|
5
|
+
// window is re-rendered. Left/right select words of the cursor line as a real text
|
|
6
|
+
// selection, so the "Search" popup of the text-selection controller picks them up.
|
|
7
|
+
export default class LogCursorController extends Controller {
|
|
8
|
+
static targets = ["lines"]
|
|
9
|
+
|
|
10
|
+
connect() {
|
|
11
|
+
this.index = null
|
|
12
|
+
this.#resetWords()
|
|
13
|
+
this.observer = new MutationObserver(() => this.#paint())
|
|
14
|
+
this.observer.observe(this.#content, { childList: true })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
disconnect() {
|
|
18
|
+
this.observer.disconnect()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
focus(event) {
|
|
22
|
+
event?.preventDefault()
|
|
23
|
+
this.#content.focus({ preventScroll: true })
|
|
24
|
+
this.activate()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
activate() {
|
|
28
|
+
if (this.index === null) this.index = this.#defaultIndex()
|
|
29
|
+
this.#paint()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The rows were thrown away (new query, new mode): an index into them means nothing now.
|
|
33
|
+
reset() {
|
|
34
|
+
this.index = null
|
|
35
|
+
this.#resetWords()
|
|
36
|
+
this.#paint()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
placeAt({ detail: { row } }) {
|
|
40
|
+
const index = this.#indexOf(row)
|
|
41
|
+
if (index === null) return
|
|
42
|
+
|
|
43
|
+
this.index = index
|
|
44
|
+
this.#resetWords()
|
|
45
|
+
this.#paint()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pointAt(event) {
|
|
49
|
+
const row = this.#rowFor(event.target)
|
|
50
|
+
if (row) this.placeAt({ detail: { row } })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
navigate(event) {
|
|
54
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return
|
|
55
|
+
|
|
56
|
+
const handled = this.#handleKey(event)
|
|
57
|
+
if (handled !== false) event.preventDefault()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#handleKey(event) {
|
|
61
|
+
switch (event.key) {
|
|
62
|
+
case "ArrowDown": case "j": return this.#moveBy(1)
|
|
63
|
+
case "ArrowUp": case "k": return this.#moveBy(-1)
|
|
64
|
+
case "PageDown": return this.#moveBy(this.#pageSize())
|
|
65
|
+
case "PageUp": return this.#moveBy(-this.#pageSize())
|
|
66
|
+
case "Home": case "g": return this.#moveTo(0)
|
|
67
|
+
case "End": case "G": return this.#moveTo(this.#rowsAmount - 1)
|
|
68
|
+
case "ArrowRight": return this.#selectWord(1, event.shiftKey)
|
|
69
|
+
case "ArrowLeft": return this.#selectWord(-1, event.shiftKey)
|
|
70
|
+
case "Enter": case "o": return this.#open()
|
|
71
|
+
case "y": return this.#copyLine()
|
|
72
|
+
case "Escape": return this.#escape()
|
|
73
|
+
default: return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#moveBy(delta) {
|
|
78
|
+
if (this.index === null) return this.activate()
|
|
79
|
+
this.#moveTo(this.index + delta)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#moveTo(index) {
|
|
83
|
+
if (this.#rowsAmount === 0) return
|
|
84
|
+
|
|
85
|
+
this.index = Math.min(Math.max(index, 0), this.#rowsAmount - 1)
|
|
86
|
+
this.#resetWords()
|
|
87
|
+
window.getSelection().removeAllRanges()
|
|
88
|
+
this.dispatch("move")
|
|
89
|
+
this.#reveal()
|
|
90
|
+
this.#paint()
|
|
91
|
+
|
|
92
|
+
// Long lines hide their match past the right edge; keep it in view.
|
|
93
|
+
const match = this.#currentRow()?.querySelector("mark.onlylogs-match")
|
|
94
|
+
if (match) this.#revealHorizontally(match)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Enter acts on what is in front of you: the selected text if there is some,
|
|
98
|
+
// otherwise the line itself.
|
|
99
|
+
#open() {
|
|
100
|
+
if (this.#hasSelectionInLines()) {
|
|
101
|
+
this.dispatch("search")
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.#currentRow()?.querySelector(".onlylogs-expand-btn")?.click()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#copyLine() {
|
|
109
|
+
const row = this.#currentRow()
|
|
110
|
+
if (!row) return
|
|
111
|
+
|
|
112
|
+
navigator.clipboard?.writeText(this.#textElement(row).textContent)
|
|
113
|
+
row.classList.remove("onlylogs-line-copied")
|
|
114
|
+
void row.offsetWidth
|
|
115
|
+
row.classList.add("onlylogs-line-copied")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#escape() {
|
|
119
|
+
if (this.#hasSelectionInLines()) {
|
|
120
|
+
window.getSelection().removeAllRanges()
|
|
121
|
+
this.#resetWords()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.#content.blur()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Words are what a log line is searched by: request ids, paths, status codes.
|
|
129
|
+
// Quotes, brackets and "=" separate them, so `status=500` offers `500` on its own.
|
|
130
|
+
#selectWord(direction, extend) {
|
|
131
|
+
const row = this.#currentRow()
|
|
132
|
+
if (!row) return this.activate()
|
|
133
|
+
|
|
134
|
+
const element = this.#textElement(row)
|
|
135
|
+
const words = [...element.textContent.matchAll(/[^\s"'=,;()[\]{}<>|]+/g)]
|
|
136
|
+
.map(match => [match.index, match.index + match[0].length])
|
|
137
|
+
if (words.length === 0) return
|
|
138
|
+
|
|
139
|
+
const current = this.focusWord ?? (direction > 0 ? -1 : words.length)
|
|
140
|
+
this.focusWord = Math.min(Math.max(current + direction, 0), words.length - 1)
|
|
141
|
+
if (!extend || this.anchorWord === null) this.anchorWord = this.focusWord
|
|
142
|
+
|
|
143
|
+
const first = words[Math.min(this.anchorWord, this.focusWord)]
|
|
144
|
+
const last = words[Math.max(this.anchorWord, this.focusWord)]
|
|
145
|
+
const range = this.#rangeFor(element, first[0], last[1])
|
|
146
|
+
|
|
147
|
+
const selection = window.getSelection()
|
|
148
|
+
selection.removeAllRanges()
|
|
149
|
+
selection.addRange(range)
|
|
150
|
+
this.#revealHorizontally(range)
|
|
151
|
+
this.dispatch("select")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#rangeFor(element, start, end) {
|
|
155
|
+
const range = document.createRange()
|
|
156
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
|
157
|
+
let offset = 0
|
|
158
|
+
|
|
159
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
160
|
+
const length = node.textContent.length
|
|
161
|
+
if (start >= offset && start <= offset + length) range.setStart(node, start - offset)
|
|
162
|
+
if (end >= offset && end <= offset + length) {
|
|
163
|
+
range.setEnd(node, end - offset)
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
offset += length
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return range
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#resetWords() {
|
|
173
|
+
this.anchorWord = null
|
|
174
|
+
this.focusWord = null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#hasSelectionInLines() {
|
|
178
|
+
const selection = window.getSelection()
|
|
179
|
+
return selection.toString().trim() !== "" && this.linesTarget.contains(selection.anchorNode)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Keeps two lines of context around the cursor, like an editor does.
|
|
183
|
+
#reveal() {
|
|
184
|
+
const lines = this.linesTarget
|
|
185
|
+
const row = this.#currentRow()
|
|
186
|
+
|
|
187
|
+
if (!row) {
|
|
188
|
+
lines.scrollTop = this.index * this.#rowHeight() - lines.clientHeight / 2
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const rowRect = row.getBoundingClientRect()
|
|
193
|
+
const top = lines.getBoundingClientRect().top
|
|
194
|
+
const margin = rowRect.height * 2
|
|
195
|
+
|
|
196
|
+
if (rowRect.top < top + margin) {
|
|
197
|
+
lines.scrollTop -= top + margin - rowRect.top
|
|
198
|
+
} else if (rowRect.bottom > top + lines.clientHeight - margin) {
|
|
199
|
+
lines.scrollTop += rowRect.bottom - (top + lines.clientHeight - margin)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#revealHorizontally(range) {
|
|
204
|
+
const lines = this.linesTarget
|
|
205
|
+
const rect = range.getBoundingClientRect()
|
|
206
|
+
const left = lines.getBoundingClientRect().left
|
|
207
|
+
|
|
208
|
+
if (rect.left < left) {
|
|
209
|
+
lines.scrollLeft -= left - rect.left + 20
|
|
210
|
+
} else if (rect.right > left + lines.clientWidth) {
|
|
211
|
+
lines.scrollLeft += rect.right - (left + lines.clientWidth) + 20
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#paint() {
|
|
216
|
+
this.#content.querySelector(".onlylogs-cursor-line")?.classList.remove("onlylogs-cursor-line")
|
|
217
|
+
if (this.index === null) return
|
|
218
|
+
|
|
219
|
+
if (this.index >= this.#rowsAmount) {
|
|
220
|
+
this.index = null
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.#currentRow()?.classList.add("onlylogs-cursor-line")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Following a live tail you are looking at its end; anywhere else at the top of the pane.
|
|
228
|
+
#defaultIndex() {
|
|
229
|
+
if (this.#rowsAmount === 0) return null
|
|
230
|
+
|
|
231
|
+
const lines = this.linesTarget
|
|
232
|
+
if (lines.scrollTop + lines.clientHeight >= lines.scrollHeight - 2) return this.#rowsAmount - 1
|
|
233
|
+
|
|
234
|
+
const top = lines.getBoundingClientRect().top
|
|
235
|
+
const firstVisible = this.#renderedRows().find(row => row.getBoundingClientRect().top >= top)
|
|
236
|
+
return firstVisible ? this.#indexOf(firstVisible) : 0
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#pageSize() {
|
|
240
|
+
return Math.max(1, Math.floor(this.linesTarget.clientHeight / this.#rowHeight()) - 2)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#rowHeight() {
|
|
244
|
+
return this.#clusterize?.options.item_height || 16
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#currentRow() {
|
|
248
|
+
if (this.index === null) return null
|
|
249
|
+
return this.#renderedRows()[this.index - this.#renderedStart] ?? null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#indexOf(row) {
|
|
253
|
+
const position = this.#renderedRows().indexOf(row)
|
|
254
|
+
return position === -1 ? null : this.#renderedStart + position
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#rowFor(element) {
|
|
258
|
+
let node = element
|
|
259
|
+
while (node && node.parentElement !== this.#content) node = node.parentElement
|
|
260
|
+
return node && !node.classList.contains("clusterize-extra-row") ? node : null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#renderedRows() {
|
|
264
|
+
return [...this.#content.children].filter(row => !row.classList.contains("clusterize-extra-row"))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#textElement(row) {
|
|
268
|
+
return row.matches("pre") ? row : row.querySelector("pre")
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
get #renderedStart() {
|
|
272
|
+
return this.#clusterize?.getRenderedStart() ?? 0
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
get #rowsAmount() {
|
|
276
|
+
return this.#clusterize?.getRowsAmount() ?? 0
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
get #clusterize() {
|
|
280
|
+
return this.application.getControllerForElementAndIdentifier(this.element, "log-streamer")?.clusterize
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
get #content() {
|
|
284
|
+
return this.linesTarget.querySelector(".clusterize-content")
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -10,12 +10,13 @@ export default class LogStreamerController extends Controller {
|
|
|
10
10
|
// false for a file nothing writes to any more: the viewer only searches, never tails.
|
|
11
11
|
liveEnabled: { type: Boolean, default: true },
|
|
12
12
|
regexpMode: { type: Boolean, default: false },
|
|
13
|
+
caseInsensitive: { type: Boolean, default: false },
|
|
13
14
|
fileSize: { type: Number, default: 0 },
|
|
14
15
|
startPosition: { type: Number, default: 0 },
|
|
15
16
|
endPosition: { type: Number, default: 0 }
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
static targets = ["logLines", "filterInput", "results", "liveButton", "searchButton", "searchWholeFileButton", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll", "rangeSliderContainer", "startSlider", "endSlider", "searchPlaceholder"];
|
|
19
|
+
static targets = ["logLines", "filterInput", "results", "liveButton", "searchButton", "searchWholeFileButton", "message", "regexpMode", "caseInsensitive", "websocketStatus", "stopButton", "clearButton", "autoscroll", "rangeSliderContainer", "startSlider", "endSlider", "searchPlaceholder"];
|
|
19
20
|
|
|
20
21
|
// When the current live query started, so "0 matches" can say since when.
|
|
21
22
|
#liveFilterStartedAt = null;
|
|
@@ -98,6 +99,7 @@ export default class LogStreamerController extends Controller {
|
|
|
98
99
|
clear() {
|
|
99
100
|
this.clusterize.clear();
|
|
100
101
|
this.#updateResultsDisplay();
|
|
102
|
+
this.dispatch("cleared");
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
toggleAutoScroll() {
|
|
@@ -126,6 +128,14 @@ export default class LogStreamerController extends Controller {
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
toggleCaseInsensitive() {
|
|
132
|
+
this.caseInsensitiveValue = this.caseInsensitiveTarget.checked;
|
|
133
|
+
this.#updateUrlParam('case_insensitive', this.caseInsensitiveValue ? 'true' : null);
|
|
134
|
+
if (this.filterInputTarget.value && this.filterInputTarget.value.trim() !== '') {
|
|
135
|
+
this.reconnectWithNewMode();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
129
139
|
switchToLive() {
|
|
130
140
|
if (this.isLiveMode() || !this.liveEnabledValue) return;
|
|
131
141
|
|
|
@@ -173,6 +183,25 @@ export default class LogStreamerController extends Controller {
|
|
|
173
183
|
this.reconnectWithNewMode();
|
|
174
184
|
}
|
|
175
185
|
|
|
186
|
+
currentSearch() {
|
|
187
|
+
return {
|
|
188
|
+
filter: this.filterInputTarget.value,
|
|
189
|
+
regexpMode: this.regexpModeTarget.checked,
|
|
190
|
+
caseInsensitive: this.caseInsensitiveTarget.checked
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
applySearch({ filter, regexpMode, caseInsensitive }) {
|
|
195
|
+
this.filterInputTarget.value = filter;
|
|
196
|
+
this.regexpModeTarget.checked = this.regexpModeValue = regexpMode;
|
|
197
|
+
this.caseInsensitiveTarget.checked = this.caseInsensitiveValue = caseInsensitive;
|
|
198
|
+
this.#updateUrlParams({
|
|
199
|
+
regexp_mode: regexpMode ? 'true' : null,
|
|
200
|
+
case_insensitive: caseInsensitive ? 'true' : null
|
|
201
|
+
});
|
|
202
|
+
this.applyFilter();
|
|
203
|
+
}
|
|
204
|
+
|
|
176
205
|
isLiveMode() {
|
|
177
206
|
return this.modeValue === 'live';
|
|
178
207
|
}
|
|
@@ -292,6 +321,7 @@ export default class LogStreamerController extends Controller {
|
|
|
292
321
|
|
|
293
322
|
if (closestPre) {
|
|
294
323
|
this.#scrollVerticallyToCenter(closestPre);
|
|
324
|
+
this.dispatch("context-line", { detail: { row: this.#rowElement(closestPre) } });
|
|
295
325
|
}
|
|
296
326
|
}
|
|
297
327
|
|
|
@@ -432,6 +462,10 @@ export default class LogStreamerController extends Controller {
|
|
|
432
462
|
this.regexpModeTarget.checked = false;
|
|
433
463
|
}
|
|
434
464
|
|
|
465
|
+
const caseInsensitive = params.get('case_insensitive') === 'true';
|
|
466
|
+
this.caseInsensitiveValue = caseInsensitive;
|
|
467
|
+
this.caseInsensitiveTarget.checked = caseInsensitive;
|
|
468
|
+
|
|
435
469
|
// Restore range
|
|
436
470
|
const startParam = params.get('start_position');
|
|
437
471
|
const endParam = params.get('end_position');
|
|
@@ -520,7 +554,8 @@ export default class LogStreamerController extends Controller {
|
|
|
520
554
|
file_path: this.filePathValue,
|
|
521
555
|
filter: this.filterInputTarget.value,
|
|
522
556
|
mode: this.modeValue,
|
|
523
|
-
regexp_mode: this.regexpModeValue
|
|
557
|
+
regexp_mode: this.regexpModeValue,
|
|
558
|
+
case_insensitive: this.caseInsensitiveValue
|
|
524
559
|
};
|
|
525
560
|
|
|
526
561
|
// Use range slider values if available and not at defaults
|
|
@@ -712,7 +747,12 @@ export default class LogStreamerController extends Controller {
|
|
|
712
747
|
no_data_class: 'clusterize-no-data',
|
|
713
748
|
keep_parity: true,
|
|
714
749
|
callbacks: {
|
|
750
|
+
clusterWillChange: () => {
|
|
751
|
+
this.dispatch("rendering", { detail: { start: this.clusterize?.getRenderedStart() ?? 0 } });
|
|
752
|
+
},
|
|
715
753
|
clusterChanged: () => {
|
|
754
|
+
this.dispatch("rendered", { detail: { start: this.clusterize?.getRenderedStart() ?? 0 } });
|
|
755
|
+
|
|
716
756
|
// Re-apply highlighting when cluster changes (for virtual scrolling).
|
|
717
757
|
// The byte_offset URL param is the highlight anchor for an explore window.
|
|
718
758
|
// Only re-highlight if we've already done initial highlight.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Marks what the query matched on every rendered line. Clusterize only keeps a
|
|
4
|
+
// window of rows in the DOM and re-renders it from HTML strings, so the marks are
|
|
5
|
+
// re-applied whenever that window changes rather than baked into the rows.
|
|
6
|
+
// The query is re-compiled as a JavaScript RegExp; ripgrep syntax that JavaScript
|
|
7
|
+
// does not understand just leaves the lines unmarked.
|
|
8
|
+
export default class MatchHighlightController extends Controller {
|
|
9
|
+
static targets = ["lines", "filterInput", "regexpMode", "caseInsensitive"]
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this.observer = new MutationObserver(records => this.highlight(records))
|
|
13
|
+
this.observer.observe(this.#content, { childList: true })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnect() {
|
|
17
|
+
this.observer.disconnect()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
highlight(records) {
|
|
21
|
+
const pattern = this.#pattern()
|
|
22
|
+
if (!pattern) return
|
|
23
|
+
|
|
24
|
+
records.flatMap(record => [...record.addedNodes])
|
|
25
|
+
.filter(node => node.nodeType === Node.ELEMENT_NODE)
|
|
26
|
+
.flatMap(row => row.matches("pre") ? [row] : [...row.querySelectorAll(":scope > pre")])
|
|
27
|
+
.forEach(line => this.#mark(line, pattern))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#pattern() {
|
|
31
|
+
const query = this.filterInputTarget.value.trim()
|
|
32
|
+
if (query === "") return null
|
|
33
|
+
|
|
34
|
+
const source = this.regexpModeTarget.checked ? query : query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
35
|
+
try {
|
|
36
|
+
return new RegExp(source, this.caseInsensitiveTarget.checked ? "gi" : "g")
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A match can span several text nodes (ANSI colour spans, file links), so each
|
|
43
|
+
// one is wrapped piece by piece. Working backwards keeps the earlier offsets valid
|
|
44
|
+
// while the text nodes are being split.
|
|
45
|
+
#mark(line, pattern) {
|
|
46
|
+
const matches = [...line.textContent.matchAll(pattern)].filter(match => match[0].length > 0)
|
|
47
|
+
if (matches.length === 0) return
|
|
48
|
+
|
|
49
|
+
const nodes = []
|
|
50
|
+
const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT)
|
|
51
|
+
let offset = 0
|
|
52
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
53
|
+
nodes.push({ node, start: offset, end: offset + node.length })
|
|
54
|
+
offset += node.length
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const match of matches.reverse()) {
|
|
58
|
+
const start = match.index
|
|
59
|
+
const end = start + match[0].length
|
|
60
|
+
|
|
61
|
+
for (const entry of [...nodes].reverse()) {
|
|
62
|
+
if (entry.start >= end || entry.end <= start) continue
|
|
63
|
+
|
|
64
|
+
const from = Math.max(start, entry.start) - entry.start
|
|
65
|
+
const to = Math.min(end, entry.end) - entry.start
|
|
66
|
+
|
|
67
|
+
if (to < entry.node.length) entry.node.splitText(to)
|
|
68
|
+
const piece = from > 0 ? entry.node.splitText(from) : entry.node
|
|
69
|
+
const mark = document.createElement("mark")
|
|
70
|
+
mark.className = "onlylogs-match"
|
|
71
|
+
piece.replaceWith(mark)
|
|
72
|
+
mark.append(piece)
|
|
73
|
+
|
|
74
|
+
entry.end = entry.start + from
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get #content() {
|
|
80
|
+
return this.linesTarget.querySelector(".clusterize-content")
|
|
81
|
+
}
|
|
82
|
+
}
|