onlylogs 0.9.0 → 0.11.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,16 +5,20 @@ 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' },
10
+ // false for a file nothing writes to any more: the viewer only searches, never tails.
11
+ liveEnabled: { type: Boolean, default: true },
11
12
  regexpMode: { type: Boolean, default: false },
12
13
  fileSize: { type: Number, default: 0 },
13
14
  startPosition: { type: Number, default: 0 },
14
15
  endPosition: { type: Number, default: 0 }
15
16
  };
16
17
 
17
- static targets = ["logLines", "filterInput", "results", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll", "rangeSliderContainer", "startSlider", "endSlider", "startOutput", "endOutput"];
18
+ static targets = ["logLines", "filterInput", "results", "liveButton", "searchButton", "searchWholeFileButton", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll", "rangeSliderContainer", "startSlider", "endSlider", "searchPlaceholder"];
19
+
20
+ // When the current live query started, so "0 matches" can say since when.
21
+ #liveFilterStartedAt = null;
18
22
 
19
23
  connect() {
20
24
  this.consumer = createConsumer();
@@ -23,7 +27,6 @@ export default class LogStreamerController extends Controller {
23
27
  this.isRunning = false;
24
28
  this.reconnectTimeout = null;
25
29
  this.isSearchFinished = true;
26
- this.lastRangeStep = null;
27
30
  this.historyUpdateTimeout = null;
28
31
  this.contextLineHighlighted = false;
29
32
 
@@ -33,13 +36,6 @@ export default class LogStreamerController extends Controller {
33
36
 
34
37
  this.#updateWebsocketStatus('disconnected');
35
38
 
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
39
  // Restore state from URL params if present
44
40
  this.#restoreStateFromUrl();
45
41
 
@@ -111,17 +107,13 @@ export default class LogStreamerController extends Controller {
111
107
  }
112
108
 
113
109
  pauseForSelection() {
114
- // Triggered by TextSelectionController#handleMouseDown via text-selection:start event
115
- // Enter "highlighting mode" - disable both autoscroll and live mode
110
+ // Triggered by TextSelectionController#handleMouseDown via text-selection:start event.
111
+ // Stopping autoscroll is enough to keep the text still under the cursor; the mode
112
+ // is the user's to change, so selecting text must never switch it.
116
113
  if (this.autoScrollValue) {
117
114
  this.autoScrollValue = false;
118
115
  this.autoscrollTarget.checked = false;
119
- }
120
-
121
- if (this.isLiveMode()) {
122
- this.liveModeTarget.checked = false;
123
- this.modeValue = 'static';
124
- this.stop();
116
+ this.#updateUrlParam('autoscroll', 'false');
125
117
  }
126
118
  }
127
119
 
@@ -134,32 +126,34 @@ export default class LogStreamerController extends Controller {
134
126
  }
135
127
  }
136
128
 
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
- }
129
+ switchToLive() {
130
+ if (this.isLiveMode() || !this.liveEnabledValue) return;
131
+
132
+ this.#clearHighlighting();
133
+ this.#setMode('live');
134
+ this.clear();
135
+ this.#setRange(0, this.fileSizeValue);
136
+ this.#updateUrlParams({ start_position: null, end_position: null, byte_offset: null });
137
+ this.reconnectWithNewMode();
138
+ }
139
+
140
+ switchToSearch() {
141
+ if (!this.isLiveMode()) return;
142
+
143
+ this.#setMode('static');
144
+ this.reconnectWithNewMode();
145
+ }
146
+
147
+ // Bound only to the "Search whole file" button, which is shown while tailing.
148
+ // Leaves live mode: runs what is already typed against the file instead of
149
+ // waiting for a matching line to arrive.
150
+ searchWholeFile() {
151
+ this.#clearHighlighting();
152
+ this.#setMode('static');
153
+ this.#setRange(0, this.fileSizeValue);
154
+ this.#updateUrlParams({ start_position: null, end_position: null, byte_offset: null });
155
+ this.reconnectWithNewMode();
156
+ this.filterInputTarget.focus();
163
157
  }
164
158
 
165
159
  applyFilter() {
@@ -172,14 +166,42 @@ export default class LogStreamerController extends Controller {
172
166
  // Update visual state
173
167
  this.updateStopButtonVisibility();
174
168
  this.#updateUrlParam('filter', filterValue || null);
175
- this.#updateUrlParam('mode', this.modeValue === 'live' ? null : 'static');
169
+ this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
170
+ this.#updateResultsDisplay();
176
171
 
177
172
  // Use the global debounced reconnection (300ms delay)
178
173
  this.reconnectWithNewMode();
179
174
  }
180
175
 
181
176
  isLiveMode() {
182
- return this.liveModeTarget.checked;
177
+ return this.modeValue === 'live';
178
+ }
179
+
180
+ // The only writer of modeValue. Everything that changes the mode goes through
181
+ // here so the switch, the URL and the toolbar layout can never disagree.
182
+ #setMode(mode) {
183
+ if (mode === 'live' && !this.liveEnabledValue) return;
184
+
185
+ this.modeValue = mode;
186
+ // Search is the only mode when live is disabled, so the URL need not say so.
187
+ this.#updateUrlParam('mode', mode === 'live' || !this.liveEnabledValue ? null : 'static');
188
+ this.#liveFilterStartedAt = mode === 'live' ? new Date() : null;
189
+ this.#syncModeControls();
190
+ }
191
+
192
+ #syncModeControls() {
193
+ const live = this.isLiveMode();
194
+ this.#syncSearchPlaceholder();
195
+
196
+ if (this.hasLiveButtonTarget) {
197
+ this.liveButtonTarget.setAttribute('aria-pressed', live);
198
+ }
199
+ if (this.hasSearchButtonTarget) {
200
+ this.searchButtonTarget.setAttribute('aria-pressed', !live);
201
+ }
202
+
203
+ this.filterInputTarget.placeholder = live ? 'follow lines matching…' : 'search the whole file…';
204
+ this.#updateResultsDisplay();
183
205
  }
184
206
 
185
207
  scroll() {
@@ -210,34 +232,16 @@ export default class LogStreamerController extends Controller {
210
232
  }, 600);
211
233
  }
212
234
 
235
+ // Clears the text only. The mode and the range belong to their own controls,
236
+ // so an × on a text field must not quietly reach over and change them.
213
237
  clearFilter() {
214
- // Clear filter and explore window to go back to pure live mode
215
238
  this.filterInputTarget.value = '';
216
- this.modeValue = 'live';
217
- this.startPositionValue = 0;
218
- this.endPositionValue = this.fileSizeValue;
219
-
220
- // Clear highlighting
221
239
  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
240
  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
241
+ this.#updateUrlParams({ filter: null, byte_offset: null });
242
+ this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
240
243
  this.reconnectWithNewMode();
244
+ this.filterInputTarget.focus();
241
245
  }
242
246
 
243
247
  stopSearch() {
@@ -257,10 +261,10 @@ export default class LogStreamerController extends Controller {
257
261
  const start = Math.max(0, byteOffset - contextBytes);
258
262
  const end = Math.min(this.fileSizeValue, byteOffset + contextBytes);
259
263
 
260
- // Clear filter and switch to static mode
264
+ // Context around a line can only be read unfiltered, so the query is dropped
265
+ // here on purpose - and the mode switch is now visible on the toolbar.
261
266
  this.filterInputTarget.value = '';
262
- this.modeValue = 'static';
263
- this.liveModeTarget.checked = false;
267
+ this.#setMode('static');
264
268
 
265
269
  // Update URL with byte_offset and range
266
270
  this.#updateUrlParam('byte_offset', byteOffset);
@@ -292,16 +296,7 @@ export default class LogStreamerController extends Controller {
292
296
  }
293
297
 
294
298
  #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' });
299
+ this.#rowElement(element).scrollIntoView({ behavior: 'smooth', block: 'center' });
305
300
  }
306
301
 
307
302
  #clearHighlighting() {
@@ -377,26 +372,21 @@ export default class LogStreamerController extends Controller {
377
372
  this.startSliderTarget.value = start;
378
373
  this.endSliderTarget.value = end;
379
374
 
380
- // Trigger range-slider controller to update visuals
381
375
  if (this.hasRangeSliderContainerTarget) {
382
- this.rangeSliderContainerTarget.dispatchEvent(new Event('input', { bubbles: true }));
376
+ this.rangeSliderContainerTarget.dispatchEvent(new CustomEvent('range-slider:refresh'));
383
377
  }
384
-
385
- // Also update visuals for log-streamer
386
- this.updateRangeVisuals();
387
378
  }
388
379
 
389
- #handleRangeUpdate() {
390
- const start = parseInt(this.startSliderTarget.value);
391
- const end = parseInt(this.endSliderTarget.value);
392
- const isDefaultRange = start === 0 && end === this.fileSizeValue;
380
+ // The slider only exists in search mode, and it only ever changes the range.
381
+ // It used to flip the mode as a side effect, which is how people ended up in
382
+ // live mode without having asked for it.
383
+ handleRangeUpdate(event) {
384
+ const { start, end } = event?.detail ?? this.#currentRange();
385
+ const isDefaultRange = this.#isFullRange(start, end);
393
386
 
394
- this.liveModeTarget.checked = isDefaultRange;
395
- this.modeValue = isDefaultRange ? 'live' : 'static';
396
387
  this.#updateUrlParams({
397
388
  start_position: isDefaultRange ? null : start,
398
- end_position: isDefaultRange ? null : end,
399
- mode: isDefaultRange ? null : 'static'
389
+ end_position: isDefaultRange ? null : end
400
390
  });
401
391
 
402
392
  // Clear byte_offset and highlighting if it falls outside the new range
@@ -412,7 +402,7 @@ export default class LogStreamerController extends Controller {
412
402
 
413
403
  resetRange() {
414
404
  this.#setRange(0, this.fileSizeValue);
415
- this.#handleRangeUpdate();
405
+ this.handleRangeUpdate();
416
406
  }
417
407
 
418
408
  #restoreStateFromUrl() {
@@ -450,16 +440,11 @@ export default class LogStreamerController extends Controller {
450
440
  const end = endParam ? parseInt(endParam) : this.fileSizeValue;
451
441
  this.#setRange(start, end);
452
442
 
453
- // 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
- }
443
+ // Calculate mode: check mode param, default to live unless live is disabled
444
+ const wantsStatic = params.get('mode') === 'static' || !this.liveEnabledValue;
445
+ this.modeValue = wantsStatic ? 'static' : 'live';
446
+ this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
447
+ this.#syncModeControls();
463
448
  }
464
449
 
465
450
  /**
@@ -497,7 +482,40 @@ export default class LogStreamerController extends Controller {
497
482
  /**
498
483
  * Handle successful connection
499
484
  */
485
+ // An empty query matches every line, so running it would stream the whole file
486
+ // back. Nothing is searched until something is typed.
487
+ #isIdleSearch() {
488
+ if (this.isLiveMode()) return false;
489
+ if (this.filterInputTarget.value.trim() !== "") return false;
490
+
491
+ // A bounded range - the slider, or the window "show around this line" opens -
492
+ // is a read of a known slice, not an unbounded scan, so it still runs.
493
+ const start = parseInt(this.startSliderTarget.value);
494
+ const end = parseInt(this.endSliderTarget.value);
495
+ return this.#isFullRange(start, end);
496
+ }
497
+
498
+ #syncSearchPlaceholder() {
499
+ if (!this.hasSearchPlaceholderTarget) return;
500
+ this.searchPlaceholderTarget.hidden = !this.#isIdleSearch();
501
+ }
502
+
503
+ #setConnectionState(status) {
504
+ this.#updateWebsocketStatus(status);
505
+ this.updateStopButtonVisibility();
506
+ }
507
+
500
508
  #handleConnected() {
509
+ this.#setConnectionState('connected');
510
+
511
+ if (this.#isIdleSearch()) {
512
+ this.isSearchFinished = true;
513
+ this.#hideMessage();
514
+ this.#updateResultsDisplay();
515
+ this.updateStopButtonVisibility();
516
+ return;
517
+ }
518
+
501
519
  const data = {
502
520
  file_path: this.filePathValue,
503
521
  filter: this.filterInputTarget.value,
@@ -509,7 +527,7 @@ export default class LogStreamerController extends Controller {
509
527
  const startSliderValue = parseInt(this.startSliderTarget.value);
510
528
  const endSliderValue = parseInt(this.endSliderTarget.value);
511
529
 
512
- if (startSliderValue > 0 || endSliderValue < this.fileSizeValue) {
530
+ if (!this.#isFullRange(startSliderValue, endSliderValue)) {
513
531
  data.start_position = startSliderValue;
514
532
  data.end_position = endSliderValue;
515
533
  } else if (this.modeValue === 'static' && this.endPositionValue > 0) {
@@ -520,24 +538,16 @@ export default class LogStreamerController extends Controller {
520
538
 
521
539
  this.subscription.perform('initialize_watcher', data);
522
540
 
523
- this.element.classList.add("log-streamer--connected");
524
- this.element.classList.remove("log-streamer--disconnected", "log-streamer--rejected");
525
- this.#updateWebsocketStatus('connected');
526
541
  this.updateStopButtonVisibility();
542
+ this.#syncSearchPlaceholder();
527
543
  }
528
544
 
529
545
  #handleDisconnected() {
530
- this.element.classList.add("log-streamer--disconnected");
531
- this.element.classList.remove("log-streamer--connected");
532
- this.#updateWebsocketStatus('disconnected');
533
- this.updateStopButtonVisibility();
546
+ this.#setConnectionState('disconnected');
534
547
  }
535
548
 
536
549
  #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();
550
+ this.#setConnectionState('rejected');
541
551
  }
542
552
 
543
553
  #handleLogLines(lines) {
@@ -581,9 +591,11 @@ export default class LogStreamerController extends Controller {
581
591
  // logLine is a JSON object: {content, byte_offset, show_expand_button}
582
592
  const { content, byte_offset, show_expand_button } = logLine;
583
593
 
584
- if (byte_offset && show_expand_button) {
594
+ const hasOffset = byte_offset != null;
595
+
596
+ if (hasOffset && show_expand_button) {
585
597
  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) {
598
+ } else if (hasOffset) {
587
599
  return `<pre data-byte-offset="${byte_offset}">${content}</pre>`;
588
600
  } else {
589
601
  return `<pre>${content}</pre>`;
@@ -591,18 +603,14 @@ export default class LogStreamerController extends Controller {
591
603
  }
592
604
 
593
605
  #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
- }
606
+ const loadingIcon = message.endsWith('...') ? '<span class="onlylogs-spin-animation">⟳</span>' : '';
607
+ this.messageTarget.innerHTML = message ? loadingIcon + message : '';
601
608
  }
602
609
 
603
610
  #handleFinish(message) {
604
611
  this.messageTarget.innerHTML = message;
605
612
  this.isSearchFinished = true;
613
+ this.#updateResultsDisplay();
606
614
  this.updateStopButtonVisibility();
607
615
  }
608
616
 
@@ -624,9 +632,40 @@ export default class LogStreamerController extends Controller {
624
632
  this.messageTarget.innerHTML = '';
625
633
  }
626
634
 
635
+ // Never silent: a live tail with a query that has not matched yet has to look
636
+ // different from a search that came back empty, or the two are indistinguishable.
627
637
  #updateResultsDisplay() {
628
- const resultsCount = this.clusterize.getRowsAmount();
629
- this.resultsTarget.textContent = `Results: ${this.#formatNumber(resultsCount)}`;
638
+ const count = this.#formatNumber(this.clusterize.getRowsAmount());
639
+ const hasFilter = this.filterInputTarget.value.trim() !== '';
640
+ const results = this.resultsTarget;
641
+
642
+ results.classList.remove('results-text--watching', 'results-text--live', 'results-text--found');
643
+
644
+ this.#syncSearchPlaceholder();
645
+
646
+ if (this.#isIdleSearch()) {
647
+ results.textContent = 'No query';
648
+ return;
649
+ }
650
+
651
+ if (!this.isLiveMode()) {
652
+ results.textContent = `Results: ${count}`;
653
+ if (this.isSearchFinished) results.classList.add('results-text--found');
654
+ return;
655
+ }
656
+
657
+ if (hasFilter) {
658
+ results.classList.add('results-text--watching');
659
+ results.textContent = `⏳ watching · ${count} new since ${this.#liveFilterSinceLabel()}`;
660
+ } else {
661
+ results.classList.add('results-text--live');
662
+ results.textContent = `${count} lines · live`;
663
+ }
664
+ }
665
+
666
+ #liveFilterSinceLabel() {
667
+ const since = this.#liveFilterStartedAt || new Date();
668
+ return since.toTimeString().slice(0, 5);
630
669
  }
631
670
 
632
671
  #formatNumber(number) {
@@ -660,15 +699,6 @@ export default class LogStreamerController extends Controller {
660
699
  }
661
700
  }
662
701
 
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
702
  #initializeClusterize() {
673
703
  this.clusterize = new window.Clusterize({
674
704
  scrollId: 'scrollArea',
@@ -682,9 +712,6 @@ export default class LogStreamerController extends Controller {
682
712
  no_data_class: 'clusterize-no-data',
683
713
  keep_parity: true,
684
714
  callbacks: {
685
- clusterWillChange: () => {
686
- // Optional: handle cluster change
687
- },
688
715
  clusterChanged: () => {
689
716
  // Re-apply highlighting when cluster changes (for virtual scrolling).
690
717
  // The byte_offset URL param is the highlight anchor for an explore window.
@@ -698,9 +725,6 @@ export default class LogStreamerController extends Controller {
698
725
  }
699
726
  }
700
727
  }
701
- },
702
- scrollingProgress: (progress) => {
703
- // Optional: handle scrolling progress
704
728
  }
705
729
  }
706
730
  });
@@ -745,67 +769,17 @@ export default class LogStreamerController extends Controller {
745
769
  }, 1000);
746
770
  }
747
771
 
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}%`);
772
+ // A range input snaps its value to `step` from `min`, so the end thumb can never
773
+ // land exactly on a file size that is not a multiple of the step. Anything within
774
+ // one step of the end is the whole file, not a range a few hundred bytes short.
775
+ #isFullRange(start, end) {
776
+ const step = Number(this.endSliderTarget.step) || 1;
799
777
 
800
- this.startOutputTarget.textContent = `${this.#formatPercent(start)}%`;
801
- this.endOutputTarget.textContent = `${this.#formatPercent(end)}%`;
778
+ return start <= 0 && end >= this.fileSizeValue - step;
802
779
  }
803
780
 
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);
781
+ // The live values of the two handles.
782
+ #currentRange() {
783
+ return { start: Number(this.startSliderTarget.value), end: Number(this.endSliderTarget.value) };
810
784
  }
811
785
  }
@@ -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
  }