onlylogs 0.9.0 → 0.10.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.
@@ -5,7 +5,6 @@ export default class LogStreamerController extends Controller {
5
5
  static values = {
6
6
  filePath: { type: String },
7
7
  autoScroll: { type: Boolean, default: true },
8
- autoStart: { type: Boolean, default: true },
9
8
  filter: { type: String, default: '' },
10
9
  mode: { type: String, default: 'live' },
11
10
  regexpMode: { type: Boolean, default: false },
@@ -14,7 +13,10 @@ export default class LogStreamerController extends Controller {
14
13
  endPosition: { type: Number, default: 0 }
15
14
  };
16
15
 
17
- static targets = ["logLines", "filterInput", "results", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll", "rangeSliderContainer", "startSlider", "endSlider", "startOutput", "endOutput"];
16
+ static targets = ["logLines", "filterInput", "results", "liveButton", "searchButton", "searchWholeFileButton", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll", "rangeSliderContainer", "startSlider", "endSlider", "searchPlaceholder"];
17
+
18
+ // When the current live query started, so "0 matches" can say since when.
19
+ #liveFilterStartedAt = null;
18
20
 
19
21
  connect() {
20
22
  this.consumer = createConsumer();
@@ -23,7 +25,6 @@ export default class LogStreamerController extends Controller {
23
25
  this.isRunning = false;
24
26
  this.reconnectTimeout = null;
25
27
  this.isSearchFinished = true;
26
- this.lastRangeStep = null;
27
28
  this.historyUpdateTimeout = null;
28
29
  this.contextLineHighlighted = false;
29
30
 
@@ -33,13 +34,6 @@ export default class LogStreamerController extends Controller {
33
34
 
34
35
  this.#updateWebsocketStatus('disconnected');
35
36
 
36
- // Listen for range-slider updates
37
- if (this.hasRangeSliderContainerTarget) {
38
- this.rangeSliderContainerTarget.addEventListener('range:update', (e) => {
39
- this.#handleRangeUpdate(e);
40
- });
41
- }
42
-
43
37
  // Restore state from URL params if present
44
38
  this.#restoreStateFromUrl();
45
39
 
@@ -111,17 +105,13 @@ export default class LogStreamerController extends Controller {
111
105
  }
112
106
 
113
107
  pauseForSelection() {
114
- // Triggered by TextSelectionController#handleMouseDown via text-selection:start event
115
- // Enter "highlighting mode" - disable both autoscroll and live mode
108
+ // Triggered by TextSelectionController#handleMouseDown via text-selection:start event.
109
+ // Stopping autoscroll is enough to keep the text still under the cursor; the mode
110
+ // is the user's to change, so selecting text must never switch it.
116
111
  if (this.autoScrollValue) {
117
112
  this.autoScrollValue = false;
118
113
  this.autoscrollTarget.checked = false;
119
- }
120
-
121
- if (this.isLiveMode()) {
122
- this.liveModeTarget.checked = false;
123
- this.modeValue = 'static';
124
- this.stop();
114
+ this.#updateUrlParam('autoscroll', 'false');
125
115
  }
126
116
  }
127
117
 
@@ -134,32 +124,34 @@ export default class LogStreamerController extends Controller {
134
124
  }
135
125
  }
136
126
 
137
- toggleLiveMode() {
138
- if (this.isLiveMode()) {
139
- // User checked - enable live mode, keep filter and start fresh tail
140
- this.#clearHighlighting();
141
- this.modeValue = 'live';
142
- this.clear();
143
- this.#setRange(0, this.fileSizeValue);
144
- this.#updateUrlParam('start_position', null);
145
- this.#updateUrlParam('end_position', null);
146
- this.#updateUrlParam('byte_offset', null);
147
- this.#updateUrlParam('mode', null);
148
- this.reconnectWithNewMode();
149
- } else {
150
- // User unchecked - disable live mode
151
- this.modeValue = 'static';
152
- this.#updateUrlParam('mode', 'static');
153
-
154
- const hasFilter = this.filterInputTarget.value && this.filterInputTarget.value.trim() !== '';
155
- if (hasFilter) {
156
- // Search with the current filter in static mode
157
- this.reconnectWithNewMode();
158
- } else {
159
- // No filter, just stop
160
- this.stop();
161
- }
162
- }
127
+ switchToLive() {
128
+ if (this.isLiveMode()) return;
129
+
130
+ this.#clearHighlighting();
131
+ this.#setMode('live');
132
+ this.clear();
133
+ this.#setRange(0, this.fileSizeValue);
134
+ this.#updateUrlParams({ start_position: null, end_position: null, byte_offset: null });
135
+ this.reconnectWithNewMode();
136
+ }
137
+
138
+ switchToSearch() {
139
+ if (!this.isLiveMode()) return;
140
+
141
+ this.#setMode('static');
142
+ this.reconnectWithNewMode();
143
+ }
144
+
145
+ // Bound only to the "Search whole file" button, which is shown while tailing.
146
+ // Leaves live mode: runs what is already typed against the file instead of
147
+ // waiting for a matching line to arrive.
148
+ searchWholeFile() {
149
+ this.#clearHighlighting();
150
+ this.#setMode('static');
151
+ this.#setRange(0, this.fileSizeValue);
152
+ this.#updateUrlParams({ start_position: null, end_position: null, byte_offset: null });
153
+ this.reconnectWithNewMode();
154
+ this.filterInputTarget.focus();
163
155
  }
164
156
 
165
157
  applyFilter() {
@@ -172,14 +164,39 @@ export default class LogStreamerController extends Controller {
172
164
  // Update visual state
173
165
  this.updateStopButtonVisibility();
174
166
  this.#updateUrlParam('filter', filterValue || null);
175
- this.#updateUrlParam('mode', this.modeValue === 'live' ? null : 'static');
167
+ this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
168
+ this.#updateResultsDisplay();
176
169
 
177
170
  // Use the global debounced reconnection (300ms delay)
178
171
  this.reconnectWithNewMode();
179
172
  }
180
173
 
181
174
  isLiveMode() {
182
- return this.liveModeTarget.checked;
175
+ return this.modeValue === 'live';
176
+ }
177
+
178
+ // The only writer of modeValue. Everything that changes the mode goes through
179
+ // here so the switch, the URL and the toolbar layout can never disagree.
180
+ #setMode(mode) {
181
+ this.modeValue = mode;
182
+ this.#updateUrlParam('mode', mode === 'live' ? null : 'static');
183
+ this.#liveFilterStartedAt = mode === 'live' ? new Date() : null;
184
+ this.#syncModeControls();
185
+ }
186
+
187
+ #syncModeControls() {
188
+ const live = this.isLiveMode();
189
+ this.#syncSearchPlaceholder();
190
+
191
+ if (this.hasLiveButtonTarget) {
192
+ this.liveButtonTarget.setAttribute('aria-pressed', live);
193
+ }
194
+ if (this.hasSearchButtonTarget) {
195
+ this.searchButtonTarget.setAttribute('aria-pressed', !live);
196
+ }
197
+
198
+ this.filterInputTarget.placeholder = live ? 'follow lines matching…' : 'search the whole file…';
199
+ this.#updateResultsDisplay();
183
200
  }
184
201
 
185
202
  scroll() {
@@ -210,34 +227,16 @@ export default class LogStreamerController extends Controller {
210
227
  }, 600);
211
228
  }
212
229
 
230
+ // Clears the text only. The mode and the range belong to their own controls,
231
+ // so an × on a text field must not quietly reach over and change them.
213
232
  clearFilter() {
214
- // Clear filter and explore window to go back to pure live mode
215
233
  this.filterInputTarget.value = '';
216
- this.modeValue = 'live';
217
- this.startPositionValue = 0;
218
- this.endPositionValue = this.fileSizeValue;
219
-
220
- // Clear highlighting
221
234
  this.#clearHighlighting();
222
-
223
- // Re-enable live mode checkbox
224
- this.liveModeTarget.checked = true;
225
-
226
- // Reset range to default for live mode
227
- this.#setRange(0, this.fileSizeValue);
228
-
229
- // Update visual state
230
235
  this.updateStopButtonVisibility();
231
-
232
- // Update URL with cleared filter
233
- this.#updateUrlParam('filter', null);
234
- this.#updateUrlParam('byte_offset', null);
235
- this.#updateUrlParam('mode', null);
236
- this.#updateUrlParam('start_position', null);
237
- this.#updateUrlParam('end_position', null);
238
-
239
- // Reconnect with cleared filter and live mode
236
+ this.#updateUrlParams({ filter: null, byte_offset: null });
237
+ this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
240
238
  this.reconnectWithNewMode();
239
+ this.filterInputTarget.focus();
241
240
  }
242
241
 
243
242
  stopSearch() {
@@ -257,10 +256,10 @@ export default class LogStreamerController extends Controller {
257
256
  const start = Math.max(0, byteOffset - contextBytes);
258
257
  const end = Math.min(this.fileSizeValue, byteOffset + contextBytes);
259
258
 
260
- // Clear filter and switch to static mode
259
+ // Context around a line can only be read unfiltered, so the query is dropped
260
+ // here on purpose - and the mode switch is now visible on the toolbar.
261
261
  this.filterInputTarget.value = '';
262
- this.modeValue = 'static';
263
- this.liveModeTarget.checked = false;
262
+ this.#setMode('static');
264
263
 
265
264
  // Update URL with byte_offset and range
266
265
  this.#updateUrlParam('byte_offset', byteOffset);
@@ -292,16 +291,7 @@ export default class LogStreamerController extends Controller {
292
291
  }
293
292
 
294
293
  #scrollVerticallyToCenter(element) {
295
- // Find the row wrapper that's a direct child of clusterize-content
296
- let row = element;
297
- while (row.parentElement && !row.parentElement.classList.contains('clusterize-content')) {
298
- row = row.parentElement;
299
- }
300
-
301
- if (!row) return;
302
-
303
- // Scroll into view first to ensure element is rendered
304
- row.scrollIntoView({ behavior: 'smooth', block: 'center' });
294
+ this.#rowElement(element).scrollIntoView({ behavior: 'smooth', block: 'center' });
305
295
  }
306
296
 
307
297
  #clearHighlighting() {
@@ -377,26 +367,21 @@ export default class LogStreamerController extends Controller {
377
367
  this.startSliderTarget.value = start;
378
368
  this.endSliderTarget.value = end;
379
369
 
380
- // Trigger range-slider controller to update visuals
381
370
  if (this.hasRangeSliderContainerTarget) {
382
- this.rangeSliderContainerTarget.dispatchEvent(new Event('input', { bubbles: true }));
371
+ this.rangeSliderContainerTarget.dispatchEvent(new CustomEvent('range-slider:refresh'));
383
372
  }
384
-
385
- // Also update visuals for log-streamer
386
- this.updateRangeVisuals();
387
373
  }
388
374
 
389
- #handleRangeUpdate() {
390
- const start = parseInt(this.startSliderTarget.value);
391
- const end = parseInt(this.endSliderTarget.value);
392
- const isDefaultRange = start === 0 && end === this.fileSizeValue;
375
+ // The slider only exists in search mode, and it only ever changes the range.
376
+ // It used to flip the mode as a side effect, which is how people ended up in
377
+ // live mode without having asked for it.
378
+ handleRangeUpdate(event) {
379
+ const { start, end } = event?.detail ?? this.#currentRange();
380
+ const isDefaultRange = this.#isFullRange(start, end);
393
381
 
394
- this.liveModeTarget.checked = isDefaultRange;
395
- this.modeValue = isDefaultRange ? 'live' : 'static';
396
382
  this.#updateUrlParams({
397
383
  start_position: isDefaultRange ? null : start,
398
- end_position: isDefaultRange ? null : end,
399
- mode: isDefaultRange ? null : 'static'
384
+ end_position: isDefaultRange ? null : end
400
385
  });
401
386
 
402
387
  // Clear byte_offset and highlighting if it falls outside the new range
@@ -412,7 +397,7 @@ export default class LogStreamerController extends Controller {
412
397
 
413
398
  resetRange() {
414
399
  this.#setRange(0, this.fileSizeValue);
415
- this.#handleRangeUpdate();
400
+ this.handleRangeUpdate();
416
401
  }
417
402
 
418
403
  #restoreStateFromUrl() {
@@ -451,15 +436,9 @@ export default class LogStreamerController extends Controller {
451
436
  this.#setRange(start, end);
452
437
 
453
438
  // Calculate mode: check mode param, default to live
454
- const modeParam = params.get('mode');
455
- if (modeParam === 'static') {
456
- this.modeValue = 'static';
457
- this.liveModeTarget.checked = false;
458
- } else {
459
- // Default to live mode
460
- this.modeValue = 'live';
461
- this.liveModeTarget.checked = true;
462
- }
439
+ this.modeValue = params.get('mode') === 'static' ? 'static' : 'live';
440
+ this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
441
+ this.#syncModeControls();
463
442
  }
464
443
 
465
444
  /**
@@ -497,7 +476,40 @@ export default class LogStreamerController extends Controller {
497
476
  /**
498
477
  * Handle successful connection
499
478
  */
479
+ // An empty query matches every line, so running it would stream the whole file
480
+ // back. Nothing is searched until something is typed.
481
+ #isIdleSearch() {
482
+ if (this.isLiveMode()) return false;
483
+ if (this.filterInputTarget.value.trim() !== "") return false;
484
+
485
+ // A bounded range - the slider, or the window "show around this line" opens -
486
+ // is a read of a known slice, not an unbounded scan, so it still runs.
487
+ const start = parseInt(this.startSliderTarget.value);
488
+ const end = parseInt(this.endSliderTarget.value);
489
+ return this.#isFullRange(start, end);
490
+ }
491
+
492
+ #syncSearchPlaceholder() {
493
+ if (!this.hasSearchPlaceholderTarget) return;
494
+ this.searchPlaceholderTarget.hidden = !this.#isIdleSearch();
495
+ }
496
+
497
+ #setConnectionState(status) {
498
+ this.#updateWebsocketStatus(status);
499
+ this.updateStopButtonVisibility();
500
+ }
501
+
500
502
  #handleConnected() {
503
+ this.#setConnectionState('connected');
504
+
505
+ if (this.#isIdleSearch()) {
506
+ this.isSearchFinished = true;
507
+ this.#hideMessage();
508
+ this.#updateResultsDisplay();
509
+ this.updateStopButtonVisibility();
510
+ return;
511
+ }
512
+
501
513
  const data = {
502
514
  file_path: this.filePathValue,
503
515
  filter: this.filterInputTarget.value,
@@ -509,7 +521,7 @@ export default class LogStreamerController extends Controller {
509
521
  const startSliderValue = parseInt(this.startSliderTarget.value);
510
522
  const endSliderValue = parseInt(this.endSliderTarget.value);
511
523
 
512
- if (startSliderValue > 0 || endSliderValue < this.fileSizeValue) {
524
+ if (!this.#isFullRange(startSliderValue, endSliderValue)) {
513
525
  data.start_position = startSliderValue;
514
526
  data.end_position = endSliderValue;
515
527
  } else if (this.modeValue === 'static' && this.endPositionValue > 0) {
@@ -520,24 +532,16 @@ export default class LogStreamerController extends Controller {
520
532
 
521
533
  this.subscription.perform('initialize_watcher', data);
522
534
 
523
- this.element.classList.add("log-streamer--connected");
524
- this.element.classList.remove("log-streamer--disconnected", "log-streamer--rejected");
525
- this.#updateWebsocketStatus('connected');
526
535
  this.updateStopButtonVisibility();
536
+ this.#syncSearchPlaceholder();
527
537
  }
528
538
 
529
539
  #handleDisconnected() {
530
- this.element.classList.add("log-streamer--disconnected");
531
- this.element.classList.remove("log-streamer--connected");
532
- this.#updateWebsocketStatus('disconnected');
533
- this.updateStopButtonVisibility();
540
+ this.#setConnectionState('disconnected');
534
541
  }
535
542
 
536
543
  #handleRejected() {
537
- this.element.classList.add("log-streamer--rejected");
538
- this.element.classList.remove("log-streamer--connected", "log-streamer--disconnected");
539
- this.#updateWebsocketStatus('rejected');
540
- this.updateStopButtonVisibility();
544
+ this.#setConnectionState('rejected');
541
545
  }
542
546
 
543
547
  #handleLogLines(lines) {
@@ -581,9 +585,11 @@ export default class LogStreamerController extends Controller {
581
585
  // logLine is a JSON object: {content, byte_offset, show_expand_button}
582
586
  const { content, byte_offset, show_expand_button } = logLine;
583
587
 
584
- if (byte_offset && show_expand_button) {
588
+ const hasOffset = byte_offset != null;
589
+
590
+ if (hasOffset && show_expand_button) {
585
591
  return `<div style="display: flex; align-items: center;"><button class="onlylogs-expand-btn" data-byte-offset="${byte_offset}" data-action="click->log-streamer#handleExpandClick">+</button><pre data-byte-offset="${byte_offset}">${content}</pre></div>`;
586
- } else if (byte_offset) {
592
+ } else if (hasOffset) {
587
593
  return `<pre data-byte-offset="${byte_offset}">${content}</pre>`;
588
594
  } else {
589
595
  return `<pre>${content}</pre>`;
@@ -591,18 +597,14 @@ export default class LogStreamerController extends Controller {
591
597
  }
592
598
 
593
599
  #handleMessage(message) {
594
- this.#hideMessage();
595
- if (message === '') {
596
- this.messageTarget.innerHTML = "";
597
- } else {
598
- const loadingIcon = message.endsWith('...') ? '<span class="onlylogs-spin-animation">⟳</span>' : '';
599
- this.messageTarget.innerHTML = loadingIcon + message;
600
- }
600
+ const loadingIcon = message.endsWith('...') ? '<span class="onlylogs-spin-animation">⟳</span>' : '';
601
+ this.messageTarget.innerHTML = message ? loadingIcon + message : '';
601
602
  }
602
603
 
603
604
  #handleFinish(message) {
604
605
  this.messageTarget.innerHTML = message;
605
606
  this.isSearchFinished = true;
607
+ this.#updateResultsDisplay();
606
608
  this.updateStopButtonVisibility();
607
609
  }
608
610
 
@@ -624,9 +626,40 @@ export default class LogStreamerController extends Controller {
624
626
  this.messageTarget.innerHTML = '';
625
627
  }
626
628
 
629
+ // Never silent: a live tail with a query that has not matched yet has to look
630
+ // different from a search that came back empty, or the two are indistinguishable.
627
631
  #updateResultsDisplay() {
628
- const resultsCount = this.clusterize.getRowsAmount();
629
- this.resultsTarget.textContent = `Results: ${this.#formatNumber(resultsCount)}`;
632
+ const count = this.#formatNumber(this.clusterize.getRowsAmount());
633
+ const hasFilter = this.filterInputTarget.value.trim() !== '';
634
+ const results = this.resultsTarget;
635
+
636
+ results.classList.remove('results-text--watching', 'results-text--live', 'results-text--found');
637
+
638
+ this.#syncSearchPlaceholder();
639
+
640
+ if (this.#isIdleSearch()) {
641
+ results.textContent = 'No query';
642
+ return;
643
+ }
644
+
645
+ if (!this.isLiveMode()) {
646
+ results.textContent = `Results: ${count}`;
647
+ if (this.isSearchFinished) results.classList.add('results-text--found');
648
+ return;
649
+ }
650
+
651
+ if (hasFilter) {
652
+ results.classList.add('results-text--watching');
653
+ results.textContent = `⏳ watching · ${count} new since ${this.#liveFilterSinceLabel()}`;
654
+ } else {
655
+ results.classList.add('results-text--live');
656
+ results.textContent = `${count} lines · live`;
657
+ }
658
+ }
659
+
660
+ #liveFilterSinceLabel() {
661
+ const since = this.#liveFilterStartedAt || new Date();
662
+ return since.toTimeString().slice(0, 5);
630
663
  }
631
664
 
632
665
  #formatNumber(number) {
@@ -660,15 +693,6 @@ export default class LogStreamerController extends Controller {
660
693
  }
661
694
  }
662
695
 
663
- getStatus() {
664
- return {
665
- isRunning: this.isRunning,
666
- filePath: this.filePathValue,
667
- lineCount: this.clusterize.getRowsAmount(),
668
- connected: this.subscription && this.subscription.identifier
669
- };
670
- }
671
-
672
696
  #initializeClusterize() {
673
697
  this.clusterize = new window.Clusterize({
674
698
  scrollId: 'scrollArea',
@@ -682,9 +706,6 @@ export default class LogStreamerController extends Controller {
682
706
  no_data_class: 'clusterize-no-data',
683
707
  keep_parity: true,
684
708
  callbacks: {
685
- clusterWillChange: () => {
686
- // Optional: handle cluster change
687
- },
688
709
  clusterChanged: () => {
689
710
  // Re-apply highlighting when cluster changes (for virtual scrolling).
690
711
  // The byte_offset URL param is the highlight anchor for an explore window.
@@ -698,9 +719,6 @@ export default class LogStreamerController extends Controller {
698
719
  }
699
720
  }
700
721
  }
701
- },
702
- scrollingProgress: (progress) => {
703
- // Optional: handle scrolling progress
704
722
  }
705
723
  }
706
724
  });
@@ -745,67 +763,17 @@ export default class LogStreamerController extends Controller {
745
763
  }, 1000);
746
764
  }
747
765
 
748
- // Range slider methods
749
- updateRangeVisuals(event) {
750
- let start = Number(this.startSliderTarget.value);
751
- let end = Number(this.endSliderTarget.value);
752
- const sliderMax = Number(this.startSliderTarget.max);
753
- const step = Number(this.startSliderTarget.step);
754
-
755
- // Snap to 100% if close to max (within 2% or one step)
756
- const threshold = Math.max(sliderMax * 0.02, step);
757
- if (end > sliderMax - threshold) {
758
- end = sliderMax;
759
- this.endSliderTarget.value = end;
760
- }
761
- if (start > sliderMax - threshold) {
762
- start = sliderMax;
763
- this.startSliderTarget.value = start;
764
- }
765
-
766
- // Enforce start <= end
767
- if (start > end) {
768
- [start, end] = [end, start];
769
- this.startSliderTarget.value = start;
770
- this.endSliderTarget.value = end;
771
- }
772
-
773
- this.#updateRangeDisplay(start, end);
774
-
775
- if (event?.type === 'change') {
776
- const step = this.#stepForRange(start, end);
777
- if (step !== this.lastRangeStep) {
778
- this.lastRangeStep = step;
779
- this.startSliderTarget.step = step;
780
- this.endSliderTarget.step = step;
781
- }
782
-
783
- this.rangeSliderContainerTarget.dispatchEvent(new CustomEvent("range:update", { detail: { start, end } }));
784
- }
785
- }
786
-
787
- #stepForRange(start, end) {
788
- const selectedBytes = Math.max(end - start, 1);
789
- const step = 10 ** Math.ceil(Math.log10(selectedBytes / 200));
790
-
791
- return Math.max(1, step);
792
- }
793
-
794
- #updateRangeDisplay(start, end) {
795
- const sliderMax = Number(this.startSliderTarget.max);
796
-
797
- this.rangeSliderContainerTarget.style.setProperty("--range-start-percent", `${(start / sliderMax) * 100}%`);
798
- this.rangeSliderContainerTarget.style.setProperty("--range-end-percent", `${(end / sliderMax) * 100}%`);
766
+ // A range input snaps its value to `step` from `min`, so the end thumb can never
767
+ // land exactly on a file size that is not a multiple of the step. Anything within
768
+ // one step of the end is the whole file, not a range a few hundred bytes short.
769
+ #isFullRange(start, end) {
770
+ const step = Number(this.endSliderTarget.step) || 1;
799
771
 
800
- this.startOutputTarget.textContent = `${this.#formatPercent(start)}%`;
801
- this.endOutputTarget.textContent = `${this.#formatPercent(end)}%`;
772
+ return start <= 0 && end >= this.fileSizeValue - step;
802
773
  }
803
774
 
804
- #formatPercent(value) {
805
- // Show 100% if within 1% of file size
806
- if (value >= this.fileSizeValue * 0.99) return '100.0';
807
- if (value <= 0) return '0.0';
808
- const percent = (value / this.fileSizeValue) * 100;
809
- return percent.toFixed(1);
775
+ // The live values of the two handles.
776
+ #currentRange() {
777
+ return { start: Number(this.startSliderTarget.value), end: Number(this.endSliderTarget.value) };
810
778
  }
811
779
  }
@@ -0,0 +1,96 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Owns the presentation of the double range slider: clamping the two handles,
4
+ // painting the selected span, formatting the byte readout and widening the step
5
+ // as the selected range grows.
6
+ //
7
+ // It holds no state of its own - the inputs are the state. Whoever sets those
8
+ // values programmatically fires `range-slider:refresh` to have them repainted,
9
+ // and gets `range-slider:change` back when a user finishes dragging.
10
+ export default class RangeSliderController extends Controller {
11
+ static targets = ["startInput", "endInput", "startOutput", "endOutput"];
12
+
13
+ connect() {
14
+ this.lastStep = null;
15
+ this.refresh();
16
+ }
17
+
18
+ // Bound to input/change on both handles. Only `change` - the end of a drag -
19
+ // is worth re-reading the file for.
20
+ updateVisuals(event) {
21
+ const { start, end } = this.#clampedValues();
22
+
23
+ this.#paint(start, end);
24
+
25
+ if (event?.type === "change") {
26
+ this.#growStep(start, end);
27
+ this.dispatch("change", { detail: { start, end } });
28
+ }
29
+ }
30
+
31
+ // The values were set from outside; repaint without reporting a change.
32
+ refresh() {
33
+ const { start, end } = this.#clampedValues();
34
+ this.#paint(start, end);
35
+ }
36
+
37
+ // Reads the handles, snapping to the ends and keeping start <= end, and
38
+ // writes back whatever it had to correct.
39
+ #clampedValues() {
40
+ let start = Number(this.startInputTarget.value);
41
+ let end = Number(this.endInputTarget.value);
42
+
43
+ const sliderMax = Number(this.startInputTarget.max);
44
+ const step = Number(this.startInputTarget.step);
45
+
46
+ // Snap to 100% if close to max (within 2% or one step)
47
+ const threshold = Math.max(sliderMax * 0.02, step);
48
+ if (end > sliderMax - threshold) end = sliderMax;
49
+ if (start > sliderMax - threshold) start = sliderMax;
50
+
51
+ if (start > end) [start, end] = [end, start];
52
+
53
+ this.startInputTarget.value = start;
54
+ this.endInputTarget.value = end;
55
+
56
+ return { start, end };
57
+ }
58
+
59
+ #paint(start, end) {
60
+ const sliderMax = Number(this.startInputTarget.max);
61
+
62
+ this.element.style.setProperty("--range-start-percent", `${(start / sliderMax) * 100}%`);
63
+ this.element.style.setProperty("--range-end-percent", `${(end / sliderMax) * 100}%`);
64
+
65
+ this.startOutputTarget.textContent = this.#formatBytes(start);
66
+ this.endOutputTarget.textContent = this.#formatBytes(end);
67
+ }
68
+
69
+ // A narrow selection wants byte-level precision; a wide one would take
70
+ // thousands of steps to cross at that resolution.
71
+ #growStep(start, end) {
72
+ const selectedBytes = Math.max(end - start, 1);
73
+ const step = Math.max(1, 10 ** Math.ceil(Math.log10(selectedBytes / 200)));
74
+
75
+ if (step === this.lastStep) return;
76
+
77
+ this.lastStep = step;
78
+ this.startInputTarget.step = step;
79
+ this.endInputTarget.step = step;
80
+ }
81
+
82
+ #formatBytes(value) {
83
+ const units = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
84
+ let size = Math.max(0, value);
85
+ let unit = 0;
86
+
87
+ while (size >= 1024 && unit < units.length - 1) {
88
+ size /= 1024;
89
+ unit += 1;
90
+ }
91
+
92
+ const rounded = unit === 0 ? size : Number(size.toFixed(1));
93
+
94
+ return `${rounded} ${units[unit]}`;
95
+ }
96
+ }
@@ -90,6 +90,7 @@ export default class TextSelectionController extends Controller {
90
90
 
91
91
  this.filterInputTarget.value = this.selectedText
92
92
  this.filterInputTarget.dispatchEvent(new Event('input', { bubbles: true }))
93
+ this.dispatch("search")
93
94
  this.hideButton()
94
95
  window.getSelection().removeAllRanges()
95
96
  }