Skip to main content

guise/devtools/
state.rs

1//! The record store behind every devtools panel.
2//!
3//! Safari's Web Inspector reads a live page; there is no equivalent firehose in
4//! a native app, so `guise` inverts it: the host *reports* what it does — a log
5//! line, a request, a storage domain — and this global keeps the rolling
6//! history the panels render. Nothing here opens a socket or reads a file, the
7//! same way [`crate::ai`] never issues the request it displays.
8//!
9//! Every store is a ring: capped, oldest-first eviction, so a long-running app
10//! cannot grow the inspector without bound. `generation` ticks on every
11//! mutation, which is what lets a panel skip work when nothing changed.
12
13use std::collections::VecDeque;
14use std::time::{Duration, Instant};
15
16use gpui::{App, Global, SharedString};
17
18/// Where a record came from in the source. Built from a gpui element's
19/// `#[track_caller]` location, or supplied by the host for its own records.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SourceRef {
22  pub file: SharedString,
23  pub line: u32,
24  pub column: u32,
25}
26
27impl SourceRef {
28  pub fn new(file: impl Into<SharedString>, line: u32, column: u32) -> Self {
29    SourceRef {
30      file: file.into(),
31      line,
32      column,
33    }
34  }
35
36  /// Just the file name, which is all the one-line displays have room for.
37  pub fn basename(&self) -> &str {
38    let file = self.file.as_ref();
39    match file.rsplit_once('/') {
40      Some((_, name)) => name,
41      None => file,
42    }
43  }
44
45  /// `foo.rs:12:5`, the form both Safari and rustc print.
46  pub fn short(&self) -> String {
47    format!("{}:{}:{}", self.basename(), self.line, self.column)
48  }
49}
50
51impl From<&'static std::panic::Location<'static>> for SourceRef {
52  fn from(loc: &'static std::panic::Location<'static>) -> Self {
53    SourceRef::new(loc.file(), loc.line(), loc.column())
54  }
55}
56
57/// Log severity. Mirrors the levels Safari's console filters by.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
59pub enum LogLevel {
60  /// `console.log` — no icon, no color.
61  #[default]
62  Log,
63  /// `console.debug` — dimmed.
64  Debug,
65  /// `console.info` — blue dot.
66  Info,
67  /// `console.warn` — amber, counted in the toolbar.
68  Warning,
69  /// `console.error` — red, counted in the toolbar.
70  Error,
71}
72
73impl LogLevel {
74  pub fn label(self) -> &'static str {
75    match self {
76      LogLevel::Log => "Log",
77      LogLevel::Debug => "Debug",
78      LogLevel::Info => "Info",
79      LogLevel::Warning => "Warning",
80      LogLevel::Error => "Error",
81    }
82  }
83
84  /// Whether the toolbar's warning/error badges count this level.
85  pub fn is_issue(self) -> bool {
86    matches!(self, LogLevel::Warning | LogLevel::Error)
87  }
88}
89
90/// One log line. `count` is the repeat tally: Safari collapses identical
91/// consecutive messages into a single row with a counter rather than scrolling
92/// the useful history away, and so do we.
93#[derive(Debug, Clone)]
94pub struct LogRecord {
95  pub id: u64,
96  pub level: LogLevel,
97  pub message: SharedString,
98  /// Expandable key/value rows shown when the row is disclosed — the native
99  /// stand-in for expanding a logged object.
100  pub details: Vec<(SharedString, SharedString)>,
101  pub source: Option<SourceRef>,
102  pub at: Duration,
103  pub count: usize,
104}
105
106impl LogRecord {
107  pub fn new(level: LogLevel, message: impl Into<SharedString>) -> Self {
108    LogRecord {
109      id: 0,
110      level,
111      message: message.into(),
112      details: Vec::new(),
113      source: None,
114      at: Duration::ZERO,
115      count: 1,
116    }
117  }
118
119  pub fn detail(mut self, key: impl Into<SharedString>, value: impl Into<SharedString>) -> Self {
120    self.details.push((key.into(), value.into()));
121    self
122  }
123
124  pub fn details(mut self, rows: impl IntoIterator<Item = (SharedString, SharedString)>) -> Self {
125    self.details.extend(rows);
126    self
127  }
128
129  pub fn source(mut self, source: SourceRef) -> Self {
130    self.source = Some(source);
131    self
132  }
133
134  /// Two records coalesce when they would render identically.
135  fn same_as(&self, other: &LogRecord) -> bool {
136    self.level == other.level && self.message == other.message && self.details == other.details
137  }
138}
139
140/// What kind of resource a request fetched. Drives the Network panel's type
141/// filter and the waterfall color, exactly as in Safari.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
143pub enum ResourceKind {
144  Document,
145  Stylesheet,
146  Image,
147  Font,
148  Script,
149  /// `XHR` and `fetch` in Safari; any app-level API call here.
150  Fetch,
151  WebSocket,
152  Media,
153  #[default]
154  Other,
155}
156
157impl ResourceKind {
158  pub fn label(self) -> &'static str {
159    match self {
160      ResourceKind::Document => "Document",
161      ResourceKind::Stylesheet => "Stylesheet",
162      ResourceKind::Image => "Image",
163      ResourceKind::Font => "Font",
164      ResourceKind::Script => "Script",
165      ResourceKind::Fetch => "Fetch",
166      ResourceKind::WebSocket => "Socket",
167      ResourceKind::Media => "Media",
168      ResourceKind::Other => "Other",
169    }
170  }
171
172  /// The set the Network panel's type filter offers, in Safari's order.
173  pub const ALL: [ResourceKind; 9] = [
174    ResourceKind::Document,
175    ResourceKind::Stylesheet,
176    ResourceKind::Image,
177    ResourceKind::Font,
178    ResourceKind::Script,
179    ResourceKind::Fetch,
180    ResourceKind::WebSocket,
181    ResourceKind::Media,
182    ResourceKind::Other,
183  ];
184}
185
186/// The phase breakdown behind the waterfall bar. Each field is the time spent
187/// in that phase, not a timestamp, so a partially-complete request is just one
188/// with later phases still zero.
189#[derive(Debug, Clone, Copy, Default, PartialEq)]
190pub struct Timings {
191  pub stalled: Duration,
192  pub dns: Duration,
193  pub connect: Duration,
194  pub tls: Duration,
195  pub request: Duration,
196  pub response: Duration,
197}
198
199impl Timings {
200  pub fn total(&self) -> Duration {
201    self.stalled + self.dns + self.connect + self.tls + self.request + self.response
202  }
203
204  /// The phases in waterfall order, skipping the ones that took no time.
205  pub fn phases(&self) -> Vec<(&'static str, Duration)> {
206    [
207      ("Stalled", self.stalled),
208      ("DNS", self.dns),
209      ("Connect", self.connect),
210      ("Secure", self.tls),
211      ("Request", self.request),
212      ("Response", self.response),
213    ]
214    .into_iter()
215    .filter(|(_, d)| !d.is_zero())
216    .collect()
217  }
218}
219
220/// How far along a request is. A record starts `Pending` and the host settles
221/// it later by id — the inspector shows the row the whole time, as Safari does.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
223pub enum RequestState {
224  #[default]
225  Pending,
226  Finished,
227  Failed,
228  Canceled,
229}
230
231/// One network request.
232#[derive(Debug, Clone)]
233pub struct NetworkRecord {
234  pub id: u64,
235  pub method: SharedString,
236  pub url: SharedString,
237  pub kind: ResourceKind,
238  pub state: RequestState,
239  pub status: Option<u16>,
240  pub status_text: SharedString,
241  pub protocol: SharedString,
242  pub remote_address: SharedString,
243  pub priority: SharedString,
244  /// Bytes on the wire, after compression.
245  pub transfer_size: u64,
246  /// Bytes after decoding — what the app actually received.
247  pub resource_size: u64,
248  pub cached: bool,
249  pub request_headers: Vec<(SharedString, SharedString)>,
250  pub response_headers: Vec<(SharedString, SharedString)>,
251  pub request_body: Option<SharedString>,
252  pub response_body: Option<SharedString>,
253  pub initiator: Option<SourceRef>,
254  pub error: Option<SharedString>,
255  /// When the request started, relative to the store's epoch. The waterfall
256  /// lays rows out against this.
257  pub start: Duration,
258  pub timings: Timings,
259}
260
261impl NetworkRecord {
262  pub fn new(method: impl Into<SharedString>, url: impl Into<SharedString>) -> Self {
263    NetworkRecord {
264      id: 0,
265      method: method.into(),
266      url: url.into(),
267      kind: ResourceKind::default(),
268      state: RequestState::Pending,
269      status: None,
270      status_text: SharedString::default(),
271      protocol: SharedString::default(),
272      remote_address: SharedString::default(),
273      priority: SharedString::default(),
274      transfer_size: 0,
275      resource_size: 0,
276      cached: false,
277      request_headers: Vec::new(),
278      response_headers: Vec::new(),
279      request_body: None,
280      response_body: None,
281      initiator: None,
282      error: None,
283      start: Duration::ZERO,
284      timings: Timings::default(),
285    }
286  }
287
288  pub fn kind(mut self, kind: ResourceKind) -> Self {
289    self.kind = kind;
290    self
291  }
292
293  pub fn status(mut self, status: u16, text: impl Into<SharedString>) -> Self {
294    self.status = Some(status);
295    self.status_text = text.into();
296    self
297  }
298
299  pub fn sizes(mut self, transfer: u64, resource: u64) -> Self {
300    self.transfer_size = transfer;
301    self.resource_size = resource;
302    self
303  }
304
305  pub fn timings(mut self, timings: Timings) -> Self {
306    self.timings = timings;
307    self
308  }
309
310  pub fn request_header(
311    mut self,
312    name: impl Into<SharedString>,
313    value: impl Into<SharedString>,
314  ) -> Self {
315    self.request_headers.push((name.into(), value.into()));
316    self
317  }
318
319  pub fn response_header(
320    mut self,
321    name: impl Into<SharedString>,
322    value: impl Into<SharedString>,
323  ) -> Self {
324    self.response_headers.push((name.into(), value.into()));
325    self
326  }
327
328  pub fn request_body(mut self, body: impl Into<SharedString>) -> Self {
329    self.request_body = Some(body.into());
330    self
331  }
332
333  pub fn response_body(mut self, body: impl Into<SharedString>) -> Self {
334    self.response_body = Some(body.into());
335    self
336  }
337
338  pub fn initiator(mut self, source: SourceRef) -> Self {
339    self.initiator = Some(source);
340    self
341  }
342
343  pub fn finished(mut self) -> Self {
344    self.state = RequestState::Finished;
345    self
346  }
347
348  pub fn failed(mut self, error: impl Into<SharedString>) -> Self {
349    self.state = RequestState::Failed;
350    self.error = Some(error.into());
351    self
352  }
353
354  /// The Name column: the last path segment, or the host for a bare origin.
355  pub fn name(&self) -> &str {
356    let url = self.url.as_ref();
357    let path = url
358      .split_once("://")
359      .map(|(_, rest)| rest)
360      .unwrap_or(url)
361      .split(['?', '#'])
362      .next()
363      .unwrap_or("");
364    match path.rsplit_once('/') {
365      Some((_, last)) if !last.is_empty() => last,
366      _ => path.split('/').next().unwrap_or(url),
367    }
368  }
369
370  /// The Domain column.
371  pub fn domain(&self) -> &str {
372    let url = self.url.as_ref();
373    let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
374    rest.split(['/', '?', '#']).next().unwrap_or(rest)
375  }
376
377  /// The Scheme column.
378  pub fn scheme(&self) -> &str {
379    self
380      .url
381      .as_ref()
382      .split_once("://")
383      .map(|(s, _)| s)
384      .unwrap_or("")
385  }
386
387  /// Whether the row renders in the error color: a transport failure, or any
388  /// 4xx/5xx response.
389  pub fn is_error(&self) -> bool {
390    self.state == RequestState::Failed || self.status.is_some_and(|s| s >= 400)
391  }
392
393  pub fn duration(&self) -> Duration {
394    self.timings.total()
395  }
396}
397
398/// What a storage domain holds. Only affects the icon and the sidebar grouping.
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
400pub enum StorageKind {
401  /// Persisted settings — the native analogue of Local Storage.
402  #[default]
403  Local,
404  /// In-memory state that dies with the process.
405  Session,
406  Cookies,
407  /// A structured store: a database, an index.
408  Database,
409  Cache,
410}
411
412impl StorageKind {
413  pub fn label(self) -> &'static str {
414    match self {
415      StorageKind::Local => "Local Storage",
416      StorageKind::Session => "Session Storage",
417      StorageKind::Cookies => "Cookies",
418      StorageKind::Database => "Databases",
419      StorageKind::Cache => "Caches",
420    }
421  }
422}
423
424/// One row in a storage domain.
425#[derive(Debug, Clone, PartialEq, Eq)]
426pub struct StorageEntry {
427  pub key: SharedString,
428  pub value: SharedString,
429  /// Extra columns — cookie domain/path/expiry, a record's type, and so on.
430  pub extra: Vec<(SharedString, SharedString)>,
431}
432
433impl StorageEntry {
434  pub fn new(key: impl Into<SharedString>, value: impl Into<SharedString>) -> Self {
435    StorageEntry {
436      key: key.into(),
437      value: value.into(),
438      extra: Vec::new(),
439    }
440  }
441
442  pub fn extra(mut self, name: impl Into<SharedString>, value: impl Into<SharedString>) -> Self {
443    self.extra.push((name.into(), value.into()));
444    self
445  }
446}
447
448/// A named collection of storage rows, listed in the Storage panel's sidebar.
449#[derive(Debug, Clone)]
450pub struct StorageDomain {
451  pub id: SharedString,
452  pub name: SharedString,
453  pub kind: StorageKind,
454  pub entries: Vec<StorageEntry>,
455  /// Extra column headers beyond Key and Value.
456  pub columns: Vec<SharedString>,
457}
458
459impl StorageDomain {
460  pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
461    StorageDomain {
462      id: id.into(),
463      name: name.into(),
464      kind: StorageKind::default(),
465      entries: Vec::new(),
466      columns: Vec::new(),
467    }
468  }
469
470  pub fn kind(mut self, kind: StorageKind) -> Self {
471    self.kind = kind;
472    self
473  }
474
475  pub fn columns(mut self, columns: impl IntoIterator<Item = SharedString>) -> Self {
476    self.columns = columns.into_iter().collect();
477    self
478  }
479
480  pub fn entry(mut self, entry: StorageEntry) -> Self {
481    self.entries.push(entry);
482    self
483  }
484
485  pub fn entries(mut self, entries: impl IntoIterator<Item = StorageEntry>) -> Self {
486    self.entries.extend(entries);
487    self
488  }
489
490  /// Total bytes, as the Storage panel's footer reports.
491  pub fn size(&self) -> u64 {
492    self
493      .entries
494      .iter()
495      .map(|e| (e.key.len() + e.value.len()) as u64)
496      .sum()
497  }
498}
499
500/// A band on the Timelines panel.
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
502pub enum TimelineKind {
503  /// A rendered frame — the band that reveals dropped frames.
504  #[default]
505  Frame,
506  Layout,
507  Paint,
508  /// App work: a handler, a task, a computation.
509  Script,
510  Network,
511}
512
513impl TimelineKind {
514  pub fn label(self) -> &'static str {
515    match self {
516      TimelineKind::Frame => "Frames",
517      TimelineKind::Layout => "Layout",
518      TimelineKind::Paint => "Rendering",
519      TimelineKind::Script => "JavaScript & Events",
520      TimelineKind::Network => "Network Requests",
521    }
522  }
523
524  pub const ALL: [TimelineKind; 5] = [
525    TimelineKind::Frame,
526    TimelineKind::Layout,
527    TimelineKind::Paint,
528    TimelineKind::Script,
529    TimelineKind::Network,
530  ];
531}
532
533/// One span on a timeline band.
534#[derive(Debug, Clone)]
535pub struct TimelineEvent {
536  pub id: u64,
537  pub kind: TimelineKind,
538  pub label: SharedString,
539  pub start: Duration,
540  pub duration: Duration,
541  pub source: Option<SourceRef>,
542}
543
544impl TimelineEvent {
545  pub fn new(kind: TimelineKind, label: impl Into<SharedString>, duration: Duration) -> Self {
546    TimelineEvent {
547      id: 0,
548      kind,
549      label: label.into(),
550      start: Duration::ZERO,
551      duration,
552      source: None,
553    }
554  }
555
556  pub fn end(&self) -> Duration {
557    self.start + self.duration
558  }
559}
560
561/// How many records each store keeps before evicting the oldest.
562#[derive(Debug, Clone, Copy)]
563pub struct Limits {
564  pub logs: usize,
565  pub network: usize,
566  pub timeline: usize,
567}
568
569impl Default for Limits {
570  fn default() -> Self {
571    Limits {
572      logs: 1000,
573      network: 1000,
574      timeline: 4000,
575    }
576  }
577}
578
579/// Everything the inspector displays, and the only mutable state it owns.
580///
581/// Installed once with [`DevToolsState::init`]; feed it through the free
582/// functions in [`crate::devtools`] (`console_log`, `network_begin`, …), which
583/// are no-ops when it was never installed. That is deliberate: instrumentation
584/// left in a release build costs a global lookup and nothing more.
585pub struct DevToolsState {
586  epoch: Instant,
587  next_id: u64,
588  generation: u64,
589  limits: Limits,
590  logs: VecDeque<LogRecord>,
591  network: VecDeque<NetworkRecord>,
592  timeline: VecDeque<TimelineEvent>,
593  storage: Vec<StorageDomain>,
594  /// Wall-clock frame durations, newest last — the Timelines FPS graph.
595  frames: VecDeque<Duration>,
596  last_frame: Option<Instant>,
597}
598
599impl Global for DevToolsState {}
600
601impl Default for DevToolsState {
602  fn default() -> Self {
603    DevToolsState::new()
604  }
605}
606
607impl DevToolsState {
608  pub fn new() -> Self {
609    DevToolsState {
610      epoch: Instant::now(),
611      next_id: 1,
612      generation: 0,
613      limits: Limits::default(),
614      logs: VecDeque::new(),
615      network: VecDeque::new(),
616      timeline: VecDeque::new(),
617      storage: Vec::new(),
618      frames: VecDeque::new(),
619      last_frame: None,
620    }
621  }
622
623  pub fn limits(mut self, limits: Limits) -> Self {
624    self.limits = limits;
625    self
626  }
627
628  /// Install as the app global. Call once at startup, before opening
629  /// [`crate::devtools::DevTools`].
630  pub fn init(self, cx: &mut App) {
631    cx.set_global(self);
632  }
633
634  /// Bumped on every mutation, so a panel can tell "nothing changed" cheaply.
635  pub fn generation(&self) -> u64 {
636    self.generation
637  }
638
639  /// Time since the store was created — the clock every record is stamped on.
640  pub fn now(&self) -> Duration {
641    self.epoch.elapsed()
642  }
643
644  fn tick(&mut self) -> u64 {
645    self.generation += 1;
646    let id = self.next_id;
647    self.next_id += 1;
648    id
649  }
650
651  // --- logs ---------------------------------------------------------------
652
653  /// Append a line, coalescing it into the previous one when identical.
654  pub fn push_log(&mut self, mut record: LogRecord) {
655    if let Some(last) = self.logs.back_mut() {
656      if last.same_as(&record) {
657        last.count += 1;
658        self.generation += 1;
659        return;
660      }
661    }
662    record.id = self.tick();
663    record.at = self.now();
664    self.logs.push_back(record);
665    while self.logs.len() > self.limits.logs {
666      self.logs.pop_front();
667    }
668  }
669
670  pub fn logs(&self) -> &VecDeque<LogRecord> {
671    &self.logs
672  }
673
674  pub fn clear_logs(&mut self) {
675    self.logs.clear();
676    self.generation += 1;
677  }
678
679  /// Warning and error tallies for the toolbar badges.
680  pub fn log_issues(&self) -> (usize, usize) {
681    let mut warnings = 0;
682    let mut errors = 0;
683    for record in &self.logs {
684      match record.level {
685        LogLevel::Warning => warnings += record.count,
686        LogLevel::Error => errors += record.count,
687        _ => {}
688      }
689    }
690    (warnings, errors)
691  }
692
693  // --- network -----------------------------------------------------------
694
695  /// Record a request that has started. Returns its id, which the host keeps
696  /// to settle the request later.
697  pub fn push_network(&mut self, mut record: NetworkRecord) -> u64 {
698    let id = self.tick();
699    record.id = id;
700    if record.start.is_zero() {
701      record.start = self.now();
702    }
703    self.network.push_back(record);
704    while self.network.len() > self.limits.network {
705      self.network.pop_front();
706    }
707    id
708  }
709
710  /// Amend a request in flight — the response landed, the transfer grew, it
711  /// failed. Silently does nothing if the record was already evicted.
712  pub fn update_network(&mut self, id: u64, f: impl FnOnce(&mut NetworkRecord)) {
713    if let Some(record) = self.network.iter_mut().find(|r| r.id == id) {
714      f(record);
715      self.generation += 1;
716    }
717  }
718
719  pub fn network(&self) -> &VecDeque<NetworkRecord> {
720    &self.network
721  }
722
723  pub fn clear_network(&mut self) {
724    self.network.clear();
725    self.generation += 1;
726  }
727
728  /// The window the waterfall is drawn against: earliest start to latest end.
729  pub fn network_span(&self) -> (Duration, Duration) {
730    let start = self
731      .network
732      .iter()
733      .map(|r| r.start)
734      .min()
735      .unwrap_or(Duration::ZERO);
736    let end = self
737      .network
738      .iter()
739      .map(|r| r.start + r.duration())
740      .max()
741      .unwrap_or(Duration::ZERO);
742    (start, end.max(start))
743  }
744
745  /// Row count, total transfer, and total resource bytes — the status bar.
746  pub fn network_totals(&self) -> (usize, u64, u64) {
747    let transfer = self.network.iter().map(|r| r.transfer_size).sum();
748    let resource = self.network.iter().map(|r| r.resource_size).sum();
749    (self.network.len(), transfer, resource)
750  }
751
752  // --- storage -----------------------------------------------------------
753
754  /// Register a domain, replacing any existing one with the same id. Hosts
755  /// call this whenever their store changes; the panel always shows the
756  /// latest snapshot.
757  pub fn set_storage(&mut self, domain: StorageDomain) {
758    self.generation += 1;
759    match self.storage.iter_mut().find(|d| d.id == domain.id) {
760      Some(existing) => *existing = domain,
761      None => self.storage.push(domain),
762    }
763  }
764
765  pub fn remove_storage(&mut self, id: &str) {
766    self.storage.retain(|d| d.id.as_ref() != id);
767    self.generation += 1;
768  }
769
770  pub fn storage(&self) -> &[StorageDomain] {
771    &self.storage
772  }
773
774  // --- timelines ---------------------------------------------------------
775
776  pub fn push_timeline(&mut self, mut event: TimelineEvent) {
777    event.id = self.tick();
778    if event.start.is_zero() {
779      event.start = self.now().saturating_sub(event.duration);
780    }
781    self.timeline.push_back(event);
782    while self.timeline.len() > self.limits.timeline {
783      self.timeline.pop_front();
784    }
785  }
786
787  pub fn timeline(&self) -> &VecDeque<TimelineEvent> {
788    &self.timeline
789  }
790
791  pub fn clear_timeline(&mut self) {
792    self.timeline.clear();
793    self.frames.clear();
794    self.last_frame = None;
795    self.generation += 1;
796  }
797
798  /// Called once per rendered frame by the inspector itself. Deriving the
799  /// interval here — rather than asking the host to report it — is what makes
800  /// the Frames band work with no wiring at all.
801  pub fn record_frame(&mut self) {
802    let now = Instant::now();
803    if let Some(previous) = self.last_frame.replace(now) {
804      let delta = now.duration_since(previous);
805      // A frame gap longer than a second means the window was idle, not
806      // slow; counting it would flatten the graph for minutes.
807      if delta < Duration::from_secs(1) {
808        self.frames.push_back(delta);
809        while self.frames.len() > 240 {
810          self.frames.pop_front();
811        }
812      }
813    }
814  }
815
816  /// Forget where the last frame landed. Called when recording stops so the
817  /// idle gap before it resumes is not measured as one enormous frame.
818  pub fn stop_frames(&mut self) {
819    self.last_frame = None;
820  }
821
822  pub fn frames(&self) -> &VecDeque<Duration> {
823    &self.frames
824  }
825
826  /// Frames per second over the recorded window, or `None` before the second
827  /// frame has been seen.
828  pub fn fps(&self) -> Option<f32> {
829    if self.frames.is_empty() {
830      return None;
831    }
832    let total: Duration = self.frames.iter().sum();
833    if total.is_zero() {
834      return None;
835    }
836    Some(self.frames.len() as f32 / total.as_secs_f32())
837  }
838
839  /// Drop every record. The toolbar's clear button, and what a host calls
840  /// when it wants a clean slate around a reproduction.
841  pub fn clear_all(&mut self) {
842    self.logs.clear();
843    self.network.clear();
844    self.timeline.clear();
845    self.frames.clear();
846    self.last_frame = None;
847    self.generation += 1;
848  }
849}
850
851/// Format a byte count the way Safari's size columns do.
852pub fn format_bytes(bytes: u64) -> String {
853  const KB: f64 = 1024.0;
854  let bytes = bytes as f64;
855  if bytes < KB {
856    format!("{} B", bytes as u64)
857  } else if bytes < KB * KB {
858    format!("{:.1} KB", bytes / KB)
859  } else if bytes < KB * KB * KB {
860    format!("{:.2} MB", bytes / (KB * KB))
861  } else {
862    format!("{:.2} GB", bytes / (KB * KB * KB))
863  }
864}
865
866/// Format a duration the way Safari's timing columns do: sub-millisecond work
867/// still reads as a number, and anything past a second switches unit.
868pub fn format_duration(duration: Duration) -> String {
869  let ms = duration.as_secs_f64() * 1000.0;
870  if ms < 1.0 {
871    format!("{:.2} ms", ms)
872  } else if ms < 1000.0 {
873    format!("{:.0} ms", ms)
874  } else {
875    format!("{:.2} s", ms / 1000.0)
876  }
877}
878
879/// Elapsed time as the log's timestamp column shows it.
880pub fn format_timestamp(at: Duration) -> String {
881  let total = at.as_secs();
882  let minutes = total / 60;
883  let seconds = total % 60;
884  format!("{:02}:{:02}.{:03}", minutes, seconds, at.subsec_millis())
885}
886
887#[cfg(test)]
888mod tests {
889  use super::*;
890
891  #[test]
892  fn identical_log_lines_coalesce() {
893    let mut state = DevToolsState::new();
894    state.push_log(LogRecord::new(LogLevel::Warning, "slow frame"));
895    state.push_log(LogRecord::new(LogLevel::Warning, "slow frame"));
896    state.push_log(LogRecord::new(LogLevel::Warning, "slow frame"));
897
898    assert_eq!(state.logs().len(), 1);
899    assert_eq!(state.logs()[0].count, 3);
900    assert_eq!(state.log_issues(), (3, 0));
901  }
902
903  #[test]
904  fn a_different_line_breaks_the_run() {
905    let mut state = DevToolsState::new();
906    state.push_log(LogRecord::new(LogLevel::Log, "a"));
907    state.push_log(LogRecord::new(LogLevel::Log, "b"));
908    state.push_log(LogRecord::new(LogLevel::Log, "a"));
909
910    assert_eq!(state.logs().len(), 3);
911    assert!(state.logs().iter().all(|r| r.count == 1));
912  }
913
914  #[test]
915  fn logs_evict_oldest_past_the_limit() {
916    let mut state = DevToolsState::new().limits(Limits {
917      logs: 3,
918      ..Limits::default()
919    });
920    for i in 0..6 {
921      state.push_log(LogRecord::new(LogLevel::Log, format!("line {i}")));
922    }
923
924    assert_eq!(state.logs().len(), 3);
925    assert_eq!(state.logs()[0].message.as_ref(), "line 3");
926    assert_eq!(state.logs()[2].message.as_ref(), "line 5");
927  }
928
929  #[test]
930  fn a_request_settles_by_id() {
931    let mut state = DevToolsState::new();
932    let id = state.push_network(NetworkRecord::new(
933      "GET",
934      "https://api.example.com/v1/users",
935    ));
936    assert_eq!(state.network()[0].state, RequestState::Pending);
937
938    state.update_network(id, |record| {
939      record.state = RequestState::Finished;
940      record.status = Some(200);
941    });
942
943    assert_eq!(state.network()[0].state, RequestState::Finished);
944    assert_eq!(state.network()[0].status, Some(200));
945  }
946
947  #[test]
948  fn settling_an_evicted_request_is_a_no_op() {
949    let mut state = DevToolsState::new().limits(Limits {
950      network: 1,
951      ..Limits::default()
952    });
953    let first = state.push_network(NetworkRecord::new("GET", "https://example.com/a"));
954    state.push_network(NetworkRecord::new("GET", "https://example.com/b"));
955
956    state.update_network(first, |record| record.status = Some(500));
957
958    assert_eq!(state.network().len(), 1);
959    assert_eq!(state.network()[0].status, None);
960  }
961
962  #[test]
963  fn url_splits_into_name_domain_and_scheme() {
964    let record = NetworkRecord::new("GET", "https://api.example.com/v1/users?page=2");
965    assert_eq!(record.name(), "users");
966    assert_eq!(record.domain(), "api.example.com");
967    assert_eq!(record.scheme(), "https");
968
969    let root = NetworkRecord::new("GET", "https://example.com/");
970    assert_eq!(root.domain(), "example.com");
971
972    let bare = NetworkRecord::new("GET", "https://example.com");
973    assert_eq!(bare.name(), "example.com");
974  }
975
976  #[test]
977  fn errors_are_status_or_transport() {
978    assert!(NetworkRecord::new("GET", "/a")
979      .status(404, "Not Found")
980      .is_error());
981    assert!(NetworkRecord::new("GET", "/a")
982      .status(500, "Server Error")
983      .is_error());
984    assert!(!NetworkRecord::new("GET", "/a")
985      .status(304, "Not Modified")
986      .is_error());
987    assert!(NetworkRecord::new("GET", "/a").failed("offline").is_error());
988  }
989
990  #[test]
991  fn timings_sum_and_drop_empty_phases() {
992    let timings = Timings {
993      dns: Duration::from_millis(4),
994      connect: Duration::from_millis(11),
995      response: Duration::from_millis(35),
996      ..Timings::default()
997    };
998
999    assert_eq!(timings.total(), Duration::from_millis(50));
1000    assert_eq!(
1001      timings.phases().iter().map(|(n, _)| *n).collect::<Vec<_>>(),
1002      vec!["DNS", "Connect", "Response"]
1003    );
1004  }
1005
1006  #[test]
1007  fn registering_a_storage_domain_twice_replaces_it() {
1008    let mut state = DevToolsState::new();
1009    state
1010      .set_storage(StorageDomain::new("prefs", "Preferences").entry(StorageEntry::new("a", "1")));
1011    state.set_storage(
1012      StorageDomain::new("prefs", "Preferences")
1013        .entry(StorageEntry::new("a", "2"))
1014        .entry(StorageEntry::new("b", "3")),
1015    );
1016
1017    assert_eq!(state.storage().len(), 1);
1018    assert_eq!(state.storage()[0].entries.len(), 2);
1019    assert_eq!(state.storage()[0].entries[0].value.as_ref(), "2");
1020  }
1021
1022  #[test]
1023  fn network_span_covers_every_row() {
1024    let mut state = DevToolsState::new();
1025    let mut first = NetworkRecord::new("GET", "/a");
1026    first.start = Duration::from_millis(100);
1027    first.timings.response = Duration::from_millis(50);
1028    let mut second = NetworkRecord::new("GET", "/b");
1029    second.start = Duration::from_millis(20);
1030    second.timings.response = Duration::from_millis(10);
1031    state.push_network(first);
1032    state.push_network(second);
1033
1034    let (start, end) = state.network_span();
1035    assert_eq!(start, Duration::from_millis(20));
1036    assert_eq!(end, Duration::from_millis(150));
1037  }
1038
1039  #[test]
1040  fn byte_and_duration_formats_match_the_columns() {
1041    assert_eq!(format_bytes(512), "512 B");
1042    assert_eq!(format_bytes(2048), "2.0 KB");
1043    assert_eq!(format_bytes(5 * 1024 * 1024), "5.00 MB");
1044
1045    assert_eq!(format_duration(Duration::from_micros(250)), "0.25 ms");
1046    assert_eq!(format_duration(Duration::from_millis(42)), "42 ms");
1047    assert_eq!(format_duration(Duration::from_millis(1500)), "1.50 s");
1048
1049    assert_eq!(format_timestamp(Duration::from_millis(63_042)), "01:03.042");
1050  }
1051
1052  #[test]
1053  fn source_refs_shorten_to_basename() {
1054    let source = SourceRef::new("crates/guise/src/button.rs", 42, 9);
1055    assert_eq!(source.basename(), "button.rs");
1056    assert_eq!(source.short(), "button.rs:42:9");
1057  }
1058}