diff --git a/.github/workflows/pr-verification.yml b/.github/workflows/pr-verification.yml index f18ec1e..0ca1b57 100644 --- a/.github/workflows/pr-verification.yml +++ b/.github/workflows/pr-verification.yml @@ -18,7 +18,21 @@ jobs: with: channel: stable - run: flutter pub get - - run: flutter analyze --no-fatal-infos + - run: dart run build_runner build --delete-conflicting-outputs + - name: Block print/debugPrint in lib/ + run: | + set -e + hits=$(grep -rEn '\b(print|debugPrint)\(' lib/ \ + --include='*.dart' \ + --exclude='*.freezed.dart' \ + --exclude='*.g.dart' || true) + if [ -n "$hits" ]; then + echo "❌ print()/debugPrint() found in lib/:" + echo "$hits" + exit 1 + fi + echo "✓ No print()/debugPrint() in lib/" + - run: flutter analyze --no-fatal-infos lib/ build-macos: name: Build macOS @@ -29,6 +43,7 @@ jobs: with: channel: stable - run: flutter pub get + - run: dart run build_runner build --delete-conflicting-outputs - run: flutter build macos --release build-windows: @@ -40,6 +55,7 @@ jobs: with: channel: stable - run: flutter pub get + - run: dart run build_runner build --delete-conflicting-outputs - run: flutter build windows --release sdk-check: diff --git a/client_sdks/devconnect-react-native/src/client.ts b/client_sdks/devconnect-react-native/src/client.ts index 296dfea..a44fdff 100644 --- a/client_sdks/devconnect-react-native/src/client.ts +++ b/client_sdks/devconnect-react-native/src/client.ts @@ -482,6 +482,14 @@ export class DevConnect { private originalFetch: typeof global.fetch | null = null; private originalXHR: typeof global.XMLHttpRequest | null = null; private originalConsole: { log: Function; warn: Function; error: Function; debug: Function; info: Function; trace?: Function } | null = null; + /** + * Map of `METHOD\0URL` keys currently in flight through the fetch + * interceptor to the number of active concurrent requests. The XHR + * interceptor (which React Native 0.85's fetch transport triggers + * internally for the same call) checks this map and suppresses its + * own duplicate report if the count is > 0. + */ + private fetchInFlight: Map = new Map(); private constructor(config: DevConnectConfig & { resolvedHost: string }) { this.config = { @@ -536,21 +544,31 @@ export class DevConnect { return dc; } - const port = config.port ?? 9090; - const shouldAuto = (config.auto ?? true) && (!config.host || config.host === 'auto'); - - const resolvedHost = shouldAuto - ? await autoDetectHost(port) - : (config.host ?? 'localhost'); - - const dc = new DevConnect({ ...config, resolvedHost }); + // Create instance immediately with placeholder host so we can patch synchronously + const dc = new DevConnect({ ...config, resolvedHost: 'localhost' }); DevConnect.instance = dc; - dc.connect(); + // Patch interceptors synchronously so no early network requests or logs are missed if (dc.config.autoInterceptFetch) dc.patchFetch(); if (dc.config.autoInterceptXHR) dc.patchXHR(); if (dc.config.autoInterceptConsole) dc.patchConsole(); + // Resolve real host and connect asynchronously in the background + const port = config.port ?? 9090; + const shouldAuto = (config.auto ?? true) && (!config.host || config.host === 'auto'); + + if (shouldAuto) { + autoDetectHost(port).then((resolvedHost) => { + dc.config.host = resolvedHost; + dc.connect(); + }).catch(() => { + dc.connect(); + }); + } else { + dc.config.host = config.host ?? 'localhost'; + dc.connect(); + } + // Auto-start monitoring plugins // Using dynamic require() to avoid circular dependency at module load time try { @@ -896,7 +914,11 @@ export class DevConnect { } const source = classifyUrl(url); - dc.send('client:network:request_start', { requestId, method, url, startTime, requestHeaders: reqHeaders, requestBody, source }); + // Track this fetch so the XHR interceptor (which fires on RN's + // internal XHR transport for the same call) can dedup against us. + const fetchKey = `${method}\0${url}`; + dc.fetchInFlight.set(fetchKey, (dc.fetchInFlight.get(fetchKey) ?? 0) + 1); + dc.send('client:network:request_start', { requestId, method, url, startTime, requestHeaders: reqHeaders, requestBody, source, via: 'fetch' }); try { const response = await originalFetch(input, init); @@ -910,14 +932,28 @@ export class DevConnect { requestId, method, url, statusCode: response.status, startTime, endTime: Date.now(), duration: Date.now() - startTime, requestHeaders: reqHeaders, responseHeaders: resHeaders, requestBody, responseBody, source, + via: 'fetch', }); + const currentCount = dc.fetchInFlight.get(fetchKey) ?? 0; + if (currentCount <= 1) { + dc.fetchInFlight.delete(fetchKey); + } else { + dc.fetchInFlight.set(fetchKey, currentCount - 1); + } return response; } catch (error: any) { dc.send('client:network:request_complete', { requestId, method, url, statusCode: 0, startTime, endTime: Date.now(), duration: Date.now() - startTime, requestHeaders: reqHeaders, requestBody, error: error?.message ?? String(error), source, + via: 'fetch', }); + const currentCount = dc.fetchInFlight.get(fetchKey) ?? 0; + if (currentCount <= 1) { + dc.fetchInFlight.delete(fetchKey); + } else { + dc.fetchInFlight.set(fetchKey, currentCount - 1); + } throw error; } }; @@ -967,29 +1003,72 @@ export class DevConnect { try { requestBody = JSON.parse(body); } catch (_) { requestBody = body; } } } - dc.send('client:network:request_start', { requestId, method, url, startTime, requestHeaders: reqHeaders, requestBody, source: classifyUrl(url) }); + // Skip the start report if the fetch interceptor already + // covers this call (see handleLoadEnd for the matching skip). + const xhrKey = `${method}\0${url}`; + const isFetchRequest = (dc.fetchInFlight.get(xhrKey) ?? 0) > 0; + if (!isFetchRequest) { + dc.send('client:network:request_start', { requestId, method, url, startTime, requestHeaders: reqHeaders, requestBody, source: classifyUrl(url), via: 'xhr' }); + } return origSend(body); }; - const handleLoadEnd = () => { + const handleLoadEnd = async () => { xhr.removeEventListener('loadend', handleLoadEnd); + // Skip if the fetch interceptor already reported this call — + // RN 0.85's fetch goes through XHR internally, so without this + // every fetch would double-fire (once via fetch path, once via + // XHR path) with different requestIds, which the server + // cannot merge downstream. + const xhrKey = `${method}\0${url}`; + const isFetchRequest = (dc.fetchInFlight.get(xhrKey) ?? 0) > 0; + if (isFetchRequest) { + return; + } const resHeaders: Record = {}; try { xhr.getAllResponseHeaders().split('\r\n').forEach((l: string) => { const i = l.indexOf(':'); if (i > 0) resHeaders[l.substring(0, i).trim()] = l.substring(i + 1).trim(); }); } catch (_) {} let responseBody: any; - // Only read responseText if responseType allows it (not blob/arraybuffer) const rt = xhr.responseType; if (!rt || rt === 'text' || (rt as string) === '') { try { responseBody = JSON.parse(xhr.responseText); } catch (_) { responseBody = xhr.responseText; } } else if (rt === 'json') { responseBody = xhr.response; } else { - responseBody = `<${rt} ${xhr.response?.size ?? xhr.response?.byteLength ?? '?'} bytes>`; + // Non-text responseType (blob, arraybuffer, ...). If the + // server's Content-Type looks like JSON or text, try to read + // it as text — saves the user from seeing `` + // when the server actually sent JSON. No size cap: a mis-set + // responseType on a JSON API response can be arbitrarily + // large, and we trust the Content-Type to tell us when the + // body is genuinely binary (image/*, video/*, audio/*, + // application/octet-stream, ...). + const ct = (resHeaders['content-type'] ?? '').toLowerCase(); + const isJsonCt = ct.includes('json') || ct.includes('+json'); + const isTextCt = ct.startsWith('text/') || ct === 'application/javascript' || ct === 'application/x-www-form-urlencoded'; + const blob = xhr.response as any; + const blobSize = blob?.size ?? blob?.byteLength ?? 0; + if ((isJsonCt || isTextCt) && blob && typeof blob.text === 'function') { + try { + const text = await blob.text(); + if (isJsonCt) { + try { responseBody = JSON.parse(text); } + catch (_) { responseBody = text; } + } else { + responseBody = text; + } + } catch (_) { + responseBody = `<${rt} ${blobSize || '?'} bytes>`; + } + } else { + responseBody = `<${rt} ${blobSize || xhr.response?.byteLength || '?'} bytes>`; + } } dc.send('client:network:request_complete', { requestId, method, url, statusCode: xhr.status, startTime, endTime: Date.now(), duration: Date.now() - startTime, requestHeaders: reqHeaders, responseHeaders: resHeaders, requestBody, responseBody, source: classifyUrl(url), + via: 'xhr', ...(xhr.status === 0 ? { error: 'Network request failed' } : {}), }); }; diff --git a/lib/components/misc/retention_hint.dart b/lib/components/misc/retention_hint.dart new file mode 100644 index 0000000..4d4ce8b --- /dev/null +++ b/lib/components/misc/retention_hint.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../../core/constants/app_constants.dart'; +import '../../core/theme/color_tokens.dart'; + +/// Count pill + optional "Showing N of M" hint used by per-feature +/// toolbars. Mirrors the pattern from the All Events header so the UX +/// stays consistent across pages. +/// +/// - Pill always shows `count` and, when [limit] is set, the cap label +/// (e.g. `87 / 100`). +/// - When [total] > [count] (i.e. the source list was longer than the +/// cap and oldest entries were dropped), a small note `Showing N of M` +/// is rendered below the pill in muted grey so the user knows older +/// entries are hidden. +class RetentionHint extends StatelessWidget { + /// Visible entry count after capping. + final int count; + + /// Source list length BEFORE capping. When > [count], a "Showing N + /// of M" note is rendered. + final int total; + + /// User-configured retention cap. `null` = no cap; pill shows just + /// `count` and the note is hidden (nothing is being trimmed). + final int? limit; + + /// Human label for the cap (e.g. `100`, `1K`, `Unlimited`). + final String limitLabel; + + const RetentionHint({ + super.key, + required this.count, + required this.total, + required this.limit, + required this.limitLabel, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final isTrimmed = limit != null && total > count; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: ColorTokens.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + limit == null ? '$count' : '$count / $limitLabel', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: ColorTokens.primary, + ), + ), + ), + if (isTrimmed) ...[ + const SizedBox(height: 2), + Text( + 'Showing $count of $total', + style: TextStyle( + fontSize: 9, + fontFamily: AppConstants.monoFontFamily, + color: isDark ? Colors.grey[600] : Colors.grey[500], + ), + ), + ], + ], + ); + } +} \ No newline at end of file diff --git a/lib/core/providers/retention_provider.dart b/lib/core/providers/retention_provider.dart new file mode 100644 index 0000000..434428e --- /dev/null +++ b/lib/core/providers/retention_provider.dart @@ -0,0 +1,118 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../preferences/app_preferences.dart'; + +/// User-facing retention caps surfaced in the Settings dropdown. +/// +/// The matching `RetentionPreset.pXXX` enum values map directly to these +/// numbers via [RetentionPresetX.limit]. Keeping them as named constants +/// means a future bump (say `kRetentionSafetyCap` 5000 → 8000) only has to +/// be done in one place — both the dropdown choice and the safety-net +/// fallback pick up the new value automatically. +const int kRetentionCap100 = 100; +const int kRetentionCap500 = 500; +const int kRetentionCap1k = 1000; + +/// Safety-net cap applied when the user picks `RetentionPreset.unlimited`. +/// The preset exposes `limit == null` (= no user cap), but a totally +/// unbounded list is a memory-leak hazard: each event source appends on +/// every WebSocket frame for the lifetime of the app. We fall back to a +/// conservative upper bound so the desktop client doesn't OOM when the +/// user opts out of explicit capping. +const int kRetentionSafetyCap = 5000; + +/// Looser cap for high-frequency, low-size streams (console logs, +/// performance samples). Each entry is small so a higher ceiling is +/// affordable, and these streams are the first thing the user notices +/// when truncated. +const int kRetentionHighVolumeCap = 10000; + +/// Preset values for the data-retention dropdown. `null` = unlimited (no +/// cap, the historical default). Stored as a `String` in the dropdown so +/// we can render human-friendly labels (`1K`, `10K`) without losing +/// precision (`5000` vs `5K`). +enum RetentionPreset { unlimited, p100, p500, p1k, p5k, p10k } + +extension RetentionPresetX on RetentionPreset { + /// `null` = no cap. Any integer = drop entries older than `state.length + /// > limit` (per-list FIFO trim). + int? get limit => switch (this) { + RetentionPreset.unlimited => null, + RetentionPreset.p100 => kRetentionCap100, + RetentionPreset.p500 => kRetentionCap500, + RetentionPreset.p1k => kRetentionCap1k, + RetentionPreset.p5k => kRetentionSafetyCap, + RetentionPreset.p10k => kRetentionHighVolumeCap, + }; + + String get label => switch (this) { + RetentionPreset.unlimited => 'Unlimited', + RetentionPreset.p100 => '$kRetentionCap100', + RetentionPreset.p500 => '$kRetentionCap500', + RetentionPreset.p1k => '1K', + RetentionPreset.p5k => '5K', + RetentionPreset.p10k => '10K', + }; +} + +/// Hard cap applied by each per-feature StateNotifier. When the cap is +/// exceeded, the oldest entries are dropped FIFO. Defaults to `Unlimited` +/// so existing behavior is preserved. +/// +/// Persisted to disk via [AppPreferences] under the key +/// `retention_limit`. Survives app restarts. +final retentionLimitProvider = + StateNotifierProvider( + (ref) => RetentionLimitNotifier(), +); + +class RetentionLimitNotifier extends StateNotifier { + RetentionLimitNotifier() : super(_load()); + + static const _key = 'retention_limit'; + + static RetentionPreset _load() { + final raw = AppPreferences().get(_key); + for (final v in RetentionPreset.values) { + if (v.name == raw) return v; + } + return RetentionPreset.unlimited; + } + + void set(RetentionPreset v) { + state = v; + AppPreferences().set(_key, v.name); + } +} + +/// View-only filter for the All Events page. Caps the rendered list to +/// the N most-recent entries but does NOT mutate the source providers — +/// flipping the value back to `Unlimited` brings every entry back. +/// Separate from [retentionLimitProvider] because the user may want a +/// strict hard cap on the underlying logs but a different (looser) +/// display ceiling on the aggregate view. +/// +/// Persisted to disk via [AppPreferences] under `all_events_display_limit`. +final allEventsDisplayLimitProvider = StateNotifierProvider< + AllEventsDisplayLimitNotifier, RetentionPreset>( + (ref) => AllEventsDisplayLimitNotifier(), +); + +class AllEventsDisplayLimitNotifier extends StateNotifier { + AllEventsDisplayLimitNotifier() : super(_load()); + + static const _key = 'all_events_display_limit'; + + static RetentionPreset _load() { + final raw = AppPreferences().get(_key); + for (final v in RetentionPreset.values) { + if (v.name == raw) return v; + } + return RetentionPreset.unlimited; + } + + void set(RetentionPreset v) { + state = v; + AppPreferences().set(_key, v.name); + } +} \ No newline at end of file diff --git a/lib/core/providers/tab_visibility_provider.dart b/lib/core/providers/tab_visibility_provider.dart index cd18563..ecdb62f 100644 --- a/lib/core/providers/tab_visibility_provider.dart +++ b/lib/core/providers/tab_visibility_provider.dart @@ -1,10 +1,46 @@ +import 'dart:convert'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../preferences/app_preferences.dart'; + /// Keys matching sidebar route paths (without leading /) enum TabKey { console, network, state, storage, database, performance, memoryLeaks, history, error } class TabVisibilityNotifier extends StateNotifier> { - TabVisibilityNotifier() : super(TabKey.values.toSet()); + TabVisibilityNotifier() : super(_load()); + + static const _key = 'tab_visibility'; + + static Set _load() { + final raw = AppPreferences().get(_key); + if (raw == null || raw.isEmpty) return TabKey.values.toSet(); + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return TabKey.values.toSet(); + final result = {}; + for (final entry in decoded) { + if (entry is String) { + for (final tab in TabKey.values) { + if (tab.name == entry) { + result.add(tab); + break; + } + } + } + } + // Defensive: if nothing matched (corrupt data), fall back to + // "all enabled" rather than "all hidden" — better UX. + return result.isEmpty ? TabKey.values.toSet() : result; + } catch (_) { + return TabKey.values.toSet(); + } + } + + void _save() { + final list = state.map((t) => t.name).toList(growable: false); + AppPreferences().set(_key, jsonEncode(list)); + } void toggle(TabKey tab) { if (state.contains(tab)) { @@ -12,10 +48,18 @@ class TabVisibilityNotifier extends StateNotifier> { } else { state = {...state, tab}; } + _save(); + } + + void enable(TabKey tab) { + state = {...state, tab}; + _save(); } - void enable(TabKey tab) => state = {...state, tab}; - void disable(TabKey tab) => state = {...state}..remove(tab); + void disable(TabKey tab) { + state = {...state}..remove(tab); + _save(); + } bool isEnabled(TabKey tab) => state.contains(tab); } @@ -43,4 +87,4 @@ bool isTabEnabled(Set enabledTabs, String routePath) { } // All, Settings — always enabled return true; -} +} \ No newline at end of file diff --git a/lib/core/theme/theme_provider.dart b/lib/core/theme/theme_provider.dart index 2d7c5a5..e9ed4a1 100644 --- a/lib/core/theme/theme_provider.dart +++ b/lib/core/theme/theme_provider.dart @@ -8,14 +8,35 @@ final themeModeProvider = StateNotifierProvider( ); class ThemeModeNotifier extends StateNotifier { - ThemeModeNotifier() : super(ThemeMode.dark); + ThemeModeNotifier() : super(_load()); + + static const _key = 'themeMode'; + + static ThemeMode _load() { + final raw = AppPreferences().get(_key); + switch (raw) { + case 'light': + return ThemeMode.light; + case 'system': + return ThemeMode.system; + case 'dark': + default: + return ThemeMode.dark; + } + } + + void _set(ThemeMode mode) { + state = mode; + AppPreferences().set(_key, mode.name); + } void toggle() { - state = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark; + _set(state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark); } - void setDark() => state = ThemeMode.dark; - void setLight() => state = ThemeMode.light; + void setDark() => _set(ThemeMode.dark); + void setLight() => _set(ThemeMode.light); + void setSystem() => _set(ThemeMode.system); } /// Auto-scroll direction: true = scroll to bottom (newest at bottom), diff --git a/lib/core/utils/list_retention.dart b/lib/core/utils/list_retention.dart new file mode 100644 index 0000000..4664375 --- /dev/null +++ b/lib/core/utils/list_retention.dart @@ -0,0 +1,43 @@ +/// Cap a list at [limit] entries, dropping the oldest (head) when the +/// cap is exceeded. Returns the input list unchanged when [limit] is +/// null (unlimited) or the list is already within bounds. +/// +/// Drops in chunks (`max(limit * 0.1, 50)`) so we don't reallocate the +/// list on every single insert — same pattern the providers used before +/// the retention setting existed. +/// +/// When [shouldDrop] is provided, candidates for removal are first +/// scored: items where `shouldDrop(item) == true` are removed before +/// ones where it is `false`. Used by the async-op notifier to clear +/// resolved/rejected entries before touching the still-pending ones. +List truncateList( + List list, + int? limit, { + bool Function(T item)? shouldDrop, +}) { + if (limit == null || list.length <= limit) return list; + final toDrop = list.length - limit; + + // When a "drop first" predicate is supplied, sort indices so that + // droppable items come first; otherwise drop the oldest (head) entries. + if (shouldDrop != null) { + final droppableIndices = []; + final keepIndices = []; + for (var i = 0; i < list.length; i++) { + (shouldDrop(list[i]) ? droppableIndices : keepIndices).add(i); + } + final victims = droppableIndices.take(toDrop).toList(); + if (victims.length < toDrop) { + // Topped up from the head of the keep list. + final need = toDrop - victims.length; + victims.addAll(keepIndices.take(need)); + } + final victimSet = victims.toSet(); + return [ + for (var i = 0; i < list.length; i++) + if (!victimSet.contains(i)) list[i], + ]; + } + + return list.sublist(toDrop); +} \ No newline at end of file diff --git a/lib/core/utils/network_url_formatter.dart b/lib/core/utils/network_url_formatter.dart new file mode 100644 index 0000000..4d1b481 --- /dev/null +++ b/lib/core/utils/network_url_formatter.dart @@ -0,0 +1,171 @@ +/// Pretty-printing for network request URLs. +/// +/// Two formats are exposed: +/// - [formatUrlCompact] — single-line, truncated. For the request card list +/// where horizontal space is scarce. Decodes percent-encoding so +/// `id%2Cname` renders as `id,name`, drops the host (it lives in the host +/// row below), and shows the first 1–2 query params followed by `+ N` +/// when more are present. +/// - [formatUrlPretty] — multi-line layout for the detail panel. Renders +/// the host on its own line, the path on the next, then each query +/// parameter on its own line, prefixed with `?` for the first and `&` +/// for the rest. Decodes percent-encoding on values, keeps keys verbatim. +library; + +/// One parsed query parameter. `key` is kept verbatim (per RFC 3986 keys +/// don't need encoding in practice), `value` is percent-decoded so the +/// UI shows `id,name,foo` instead of `id%2Cname%2Cfoo`. +class FormattedQueryParam { + final String key; + final String value; + const FormattedQueryParam(this.key, this.value); +} + +/// Result of [formatUrlCompact] / [formatUrlPretty]. Always non-null when +/// the input parses as a URI; falls back to a single-line `raw` view when +/// it doesn't. +class FormattedUrl { + final String? host; + final String path; + final List queryParams; + final String raw; + + const FormattedUrl({ + required this.host, + required this.path, + required this.queryParams, + required this.raw, + }); + + bool get hasQuery => queryParams.isNotEmpty; +} + +/// Parses [url] into a [FormattedUrl]. Returns null if [url] is empty or +/// the SDK already marked it as ``. Malformed URLs fall +/// through to a single-field `FormattedUrl` with `host`/`path` null. +FormattedUrl? parseFormattedUrl(String? url) { + if (url == null) return null; + final trimmed = url.trim(); + if (trimmed.isEmpty || trimmed == '') return null; + + Uri uri; + try { + uri = Uri.parse(trimmed); + } catch (_) { + return FormattedUrl(host: null, path: trimmed, queryParams: const [], raw: trimmed); + } + + try { + // `uri.queryParametersAll` keeps insertion order AND preserves repeated + // keys as lists — the former matters for `?order=` style params, the + // latter for Supabase-style `?id=in.(1,2,3)` filters. Can throw + // `FormatException` on invalid percent-encoding (e.g. `?q=%g1`). + final params = []; + uri.queryParametersAll.forEach((k, values) { + for (final v in values) { + params.add(FormattedQueryParam(k, _decode(v))); + } + }); + + return FormattedUrl( + host: uri.host.isEmpty ? null : uri.host, + path: uri.path.isEmpty ? '/' : uri.path, + queryParams: params, + raw: trimmed, + ); + } catch (_) { + return FormattedUrl( + host: uri.host.isEmpty ? null : uri.host, + path: uri.path.isEmpty ? '/' : uri.path, + queryParams: const [], + raw: trimmed); + } +} + +/// Compact single-line view: `/path ?key=value, key2=value2 + N more`. +/// Truncates to [maxLength] with an ellipsis. Drops the host (it has its +/// own slot in the card row). +String formatUrlCompact(String? url, {int maxLength = 100}) { + final parsed = parseFormattedUrl(url); + if (parsed == null) return ''; + if (parsed.queryParams.isEmpty) return parsed.path; + + const previewCount = 2; + final preview = parsed.queryParams + .take(previewCount) + .map((p) => p.value.isEmpty ? p.key : '${p.key}=${p.value}') + .join(', '); + final remaining = parsed.queryParams.length - previewCount; + final tail = remaining > 0 ? ' + $remaining' : ''; + final line = '${parsed.path} ? $preview$tail'; + return line.length > maxLength ? '${line.substring(0, maxLength - 1)}…' : line; +} + +/// Two-line pretty view for the detail panel: +/// +/// https://host.example.com/rest/v1/legal_documents +/// ?select=id,name,...&audience=eq.customer&language=eq.vi&... +/// +/// Stays at exactly two lines (scheme+host+path on top, all query params +/// joined on bottom) regardless of how many params the URL has. Pair +/// with `maxLines: 2` + `TextOverflow.ellipsis` in the widget so an +/// over-wide params line truncates instead of wrapping. Values are +/// percent-decoded. +String formatUrlPretty(String? url) { + final parsed = parseFormattedUrl(url); + if (parsed == null) return ''; + final scheme = Uri.tryParse(parsed.raw)?.scheme; + final showScheme = scheme != null && scheme.isNotEmpty; + final hostPart = parsed.host ?? ''; + final line1 = showScheme + ? '$scheme://$hostPart${parsed.path}' + : (hostPart.isEmpty ? parsed.path : '$hostPart${parsed.path}'); + if (parsed.queryParams.isEmpty) return line1; + final line2 = parsed.queryParams + .map((p) => p.value.isEmpty ? p.key : '${p.key}=${p.value}') + .join('&'); + return '$line1\n?$line2'; +} + +/// Decodes a percent-encoded string. Falls back to the original input on +/// malformed escapes (which `Uri.decodeQueryComponent` would throw on) so +/// the UI never crashes on a bad URL. +String _decode(String input) { + try { + return Uri.decodeQueryComponent(input); + } catch (_) { + return input; + } +} + +/// Number of query parameters — for the card row's " + N" hint and for +/// the `?5 params` fallback when [formatUrlCompact] is in tight mode. +int queryParamCount(String? url) => parseFormattedUrl(url)?.queryParams.length ?? 0; + +/// Single-line, percent-decoded URL — what `Copy URL` puts on the +/// clipboard. Still a valid URL when pasted into a browser / Postman +/// (host + path + `?k=v&k=v`), but with `%2C` → `,` and friends so the +/// pasted value is human-readable. Falls back to the raw input when the +/// value isn't a parseable URL. +String formatUrlOneLine(String? url) { + if (url == null) return ''; + final parsed = parseFormattedUrl(url); + if (parsed == null) return ''; + // Malformed-URL fallback: parser returned a single raw path with no + // host and no params. Return the raw input verbatim. + if (parsed.host == null && parsed.queryParams.isEmpty && + parsed.path == parsed.raw) { + return parsed.raw; + } + final scheme = Uri.tryParse(parsed.raw)?.scheme; + final schemePrefix = + (scheme != null && scheme.isNotEmpty) ? '$scheme://' : ''; + final buf = StringBuffer('$schemePrefix${parsed.host ?? ''}${parsed.path}'); + for (var i = 0; i < parsed.queryParams.length; i++) { + final p = parsed.queryParams[i]; + buf.write(i == 0 ? '?' : '&'); + buf.write(p.key); + if (p.value.isNotEmpty) buf.write('=${p.value}'); + } + return buf.toString(); +} diff --git a/lib/core/utils/retention_capped.dart b/lib/core/utils/retention_capped.dart new file mode 100644 index 0000000..231f514 --- /dev/null +++ b/lib/core/utils/retention_capped.dart @@ -0,0 +1,40 @@ +/// Result of applying a retention cap to a source list. +/// +/// - [items] — the most recent [limit] entries (or the full list when +/// no cap is set / list fits under the cap). +/// - [total] — lifetime count of entries received, including ones +/// dropped by the cap. Used by the page header to surface a +/// "Showing N of M" hint when entries have been dropped. +class RetentionCapped { + final List items; + final int total; + + const RetentionCapped({required this.items, required this.total}); + + /// True when the source was longer than [limit] and some entries were + /// dropped from the head of the list. + bool get isTrimmed => total > items.length; +} + +/// Apply a retention cap to [source], keeping the most recent [limit] +/// entries. [limit] is the user-configured retention cap; null = no cap. +/// +/// Cheap O(n) operation. Used by per-feature display providers so that +/// the page header can surface a "Showing N of M" note when older entries +/// are being hidden by the cap. +RetentionCapped applyRetentionCap( + List source, + int? limit, { + int? totalSeen, +}) { + if (limit == null || source.length <= limit) { + return RetentionCapped( + items: source, + total: totalSeen ?? source.length, + ); + } + return RetentionCapped( + items: source.sublist(source.length - limit), + total: totalSeen ?? source.length, + ); +} \ No newline at end of file diff --git a/lib/features/all_events/presentation/detail/error_detail.dart b/lib/features/all_events/presentation/detail/error_detail.dart index 5196c25..7cbb867 100644 --- a/lib/features/all_events/presentation/detail/error_detail.dart +++ b/lib/features/all_events/presentation/detail/error_detail.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../../../components/text/text_component.dart'; import '../../../../core/constants/app_constants.dart'; import '../../../../core/utils/smooth_scroll_controller.dart'; import '../../../../core/utils/toast_utils.dart'; @@ -76,7 +77,7 @@ class _ErrorDetailState extends State { ], ), const SizedBox(height: 6), - Text( + TextComponent( entry.message, style: TextStyle( fontFamily: AppConstants.monoFontFamily, @@ -159,7 +160,7 @@ class _DetailRow extends StatelessWidget { ), ), Expanded( - child: Text( + child: TextComponent( value, style: TextStyle( fontFamily: AppConstants.monoFontFamily, diff --git a/lib/features/all_events/presentation/detail/event_detail_panel.dart b/lib/features/all_events/presentation/detail/event_detail_panel.dart index b6265ef..83b95b8 100644 --- a/lib/features/all_events/presentation/detail/event_detail_panel.dart +++ b/lib/features/all_events/presentation/detail/event_detail_panel.dart @@ -26,6 +26,7 @@ import '../../../../models/log/log_entry.dart'; import '../../../../models/network/network_entry.dart'; import '../../../../models/state/state_change.dart'; import '../../../../models/storage/storage_entry.dart'; +import '../../../../core/utils/network_url_formatter.dart'; import '../../../../server/providers/server_providers.dart'; import '../../provider/all_events_provider.dart'; import '../buttons/pressable_button.dart'; @@ -675,10 +676,11 @@ class _EventDetailPanelState extends ConsumerState { ], Expanded( child: TextComponent( - entry.url, + formatUrlPretty(entry.url), style: TextStyle( fontFamily: AppConstants.monoFontFamily, fontSize: 11, + height: 1.35, color: isDark ? ColorTokens.lightBackground : Colors.black87, ), @@ -1113,259 +1115,6 @@ class _EventDetailPanelState extends ConsumerState { ); } - Widget _buildTabScreenshotWidget( - ThemeData theme, bool isDark, int tabIndex) { - final event = widget.event; - final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(event.timestamp), - ); - - final (typeColor, typeIcon, typeLabel) = - DetailHeader.staticTypeDetails(event.type); - - final header = Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - ), - child: Row( - children: [ - Icon(typeIcon, size: 14, color: typeColor), - const SizedBox(width: 8), - TextComponent(typeLabel, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: typeColor)), - const SizedBox(width: 10), - TextComponent(time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 10, - color: Colors.grey[500])), - ], - ), - ); - - Widget tabContent; - if (event.type == EventType.network && event.rawData is NetworkEntry) { - final entry = event.rawData as NetworkEntry; - final urlBar = Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, - border: Border( - bottom: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.05) - : Colors.black.withValues(alpha: 0.06), - ), - ), - ), - child: Row( - children: [ - HttpMethodBadge(method: entry.method), - const SizedBox(width: 8), - if (entry.isComplete) ...[ - StatusBadge(statusCode: entry.statusCode), - const SizedBox(width: 8), - ], - Expanded( - child: TextComponent(entry.url, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark - ? ColorTokens.lightBackground - : Colors.black87)), - ), - ], - ), - ); - final timingBar = entry.duration != null - ? Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: TimingBar(duration: entry.duration!), - ) - : null; - - const tabNames = ['Headers', 'Request', 'Response', 'Timing']; - final tabLabel = Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: SectionLabel(tabNames[tabIndex]), - ); - - Widget body; - switch (tabIndex) { - case 0: - body = Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionLabel('Request Headers'), - const SizedBox(height: 8), - HeaderTable(headers: entry.requestHeaders, isScreenshot: true), - const SizedBox(height: 20), - const SectionLabel('Response Headers'), - const SizedBox(height: 8), - HeaderTable(headers: entry.responseHeaders, isScreenshot: true), - ], - ), - ); - break; - case 1: - body = _buildBodyScreenshot( - entry.requestBody, 'Request Body', isDark); - break; - case 2: - body = _buildBodyScreenshot( - entry.responseBody, 'Response Body', isDark); - break; - case 3: - default: - body = Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InfoRow( - 'Start Time', - DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.startTime), - ), - ), - if (entry.endTime != null) - InfoRow( - 'End Time', - DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( - DateTime.fromMillisecondsSinceEpoch(entry.endTime!), - ), - ), - if (entry.duration != null) - InfoRow('Duration', formatDuration(entry.duration!)), - if (entry.error != null) ...[ - const SizedBox(height: 12), - const SectionLabel('Error'), - const SizedBox(height: 6), - ErrorBlock(text: entry.error!, isDark: isDark), - ], - ], - ), - ); - break; - } - - tabContent = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - urlBar, - if (timingBar != null) timingBar, - const Divider(height: 1), - tabLabel, - body, - ], - ); - } else if (event.type == EventType.state && - event.rawData is StateChange) { - final entry = event.rawData as StateChange; - const tabNames = ['Diff', 'Previous', 'Next']; - Widget body; - switch (tabIndex) { - case 0: - body = entry.diff.isEmpty - ? const Padding( - padding: EdgeInsets.all(16), - child: TextComponent('No diff')) - : Padding( - padding: const EdgeInsets.all(12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: - entry.diff.map((d) => DiffRow(diff: d)).toList(), - ), - ); - break; - case 1: - body = _buildBodyScreenshot( - entry.previousState.isEmpty ? null : entry.previousState, - 'Previous State', - isDark); - break; - case 2: - default: - body = _buildBodyScreenshot( - entry.nextState.isEmpty ? null : entry.nextState, - 'Next State', - isDark); - break; - } - tabContent = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: SectionLabel(tabNames[tabIndex]), - ), - body, - ], - ); - } else { - tabContent = _buildScreenshotContent(isDark); - } - - return Container( - color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - header, - const Divider(height: 1), - tabContent, - ], - ), - ); - } - - Widget _buildBodyScreenshot(dynamic body, String label, bool isDark) { - if (body == null) { - return Padding( - padding: const EdgeInsets.all(16), - child: TextComponent('No $label', - style: TextStyle(color: Colors.grey[500], fontSize: 12)), - ); - } - dynamic parsed = body; - if (parsed is String) { - try { - parsed = jsonDecode(parsed); - } catch (_) {} - } - final useJson = _currentJsonMode; - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionLabel(label), - const SizedBox(height: 8), - if (useJson || !(parsed is Map || parsed is List)) - JsonPrettyViewer(data: parsed) - else - JsonViewer(data: parsed, initiallyExpanded: true), - ], - ), - ); - } - @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; diff --git a/lib/features/all_events/presentation/detail/log_detail.dart b/lib/features/all_events/presentation/detail/log_detail.dart index fb3108c..d7e7b94 100644 --- a/lib/features/all_events/presentation/detail/log_detail.dart +++ b/lib/features/all_events/presentation/detail/log_detail.dart @@ -7,19 +7,19 @@ import '../../../../components/misc/status_badge.dart'; import '../../../../core/utils/smooth_scroll_controller.dart'; import '../../../../core/utils/toast_utils.dart'; import '../../../../models/log/log_entry.dart'; +import '../shared/body_view.dart' show InlineJsonView; import '../shared/code_block.dart'; import '../shared/copy_button.dart'; import '../shared/error_block.dart'; import '../shared/section_label.dart'; import '../shared/tag_chip.dart'; -import 'log_inline_json.dart'; /// Right-pane detail for log events. /// /// Renders the level badge + optional tag chip + copy button in the header -/// row, then either a [CodeBlock] (for plain text) or a [LogInlineJson] -/// (for embedded JSON), and finally the optional metadata block and stack -/// trace. +/// row, then a [CodeBlock] (for plain text) or an [InlineJsonView] with +/// the Tree/JSON/Code toggle (for embedded JSON), and finally the +/// optional metadata block and stack trace. class LogDetail extends StatefulWidget { final LogEntry entry; @@ -97,12 +97,12 @@ class _LogDetailState extends State { CodeBlock(text: jsonResult.$1, isDark: isDark), const SizedBox(height: 10), ], - LogInlineJson(data: jsonResult.$2, label: 'Data'), + InlineJsonView(data: jsonResult.$2, label: 'Data'), ] else CodeBlock(text: entry.message, isDark: isDark), if (entry.metadata != null && entry.metadata!.isNotEmpty) ...[ const SizedBox(height: 16), - LogInlineJson(data: entry.metadata, label: 'Metadata'), + InlineJsonView(data: entry.metadata, label: 'Metadata'), ], if (entry.stackTrace != null) ...[ const SizedBox(height: 16), diff --git a/lib/features/all_events/presentation/detail/network_detail.dart b/lib/features/all_events/presentation/detail/network_detail.dart index dbd3633..0a85385 100644 --- a/lib/features/all_events/presentation/detail/network_detail.dart +++ b/lib/features/all_events/presentation/detail/network_detail.dart @@ -10,12 +10,14 @@ import '../../../../components/text/text_component.dart'; import '../../../../components/viewers/json_viewer.dart'; import '../../../../core/constants/app_constants.dart'; import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/utils/network_url_formatter.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../core/utils/duration_format.dart'; import '../../../../core/utils/toast_utils.dart'; import '../../../../models/network/network_entry.dart'; import '../shared/body_view.dart'; import '../shared/detail_tab_bar.dart'; +import '../shared/params_tab.dart'; import '../network/headers_view.dart'; import '../network/timing_view.dart'; import '../shared/copy_button.dart'; @@ -53,15 +55,24 @@ class _NetworkDetailState extends ConsumerState _tabController.addListener(_onTabIndexChange); } - TabController _makeController([int initialIndex = 0]) { + TabController _makeController([int initialIndex = 0, int length = 4]) { return TabController( - length: 4, + length: length, vsync: this, animationDuration: ref.read(tabAnimationProvider), initialIndex: initialIndex, ); } + void _resizeControllerIfNeeded(int newLength) { + if (_tabController.length == newLength) return; + final oldIndex = _tabController.index.clamp(0, newLength - 1); + _tabController.removeListener(_onTabIndexChange); + _tabController.dispose(); + _tabController = _makeController(oldIndex, newLength); + _tabController.addListener(_onTabIndexChange); + } + void _onTabIndexChange() { if (!_tabController.indexIsChanging) { widget.onTabChanged?.call(_tabController.index); @@ -70,13 +81,28 @@ class _NetworkDetailState extends ConsumerState void _rebuildController() { final oldIndex = _tabController.index; + final length = _tabController.length; _tabController.removeListener(_onTabIndexChange); _tabController.dispose(); - _tabController = _makeController(oldIndex); + _tabController = _makeController(oldIndex, length); _tabController.addListener(_onTabIndexChange); setState(() {}); } + @override + void didUpdateWidget(NetworkDetail old) { + super.didUpdateWidget(old); + // Resize the TabController only when the URL (and therefore the + // "Params" tab visibility) actually changes. Doing this in + // `build()` disposes the controller mid-build which is unsafe. + if (old.entry.url != widget.entry.url) { + final hasParams = + (Uri.tryParse(widget.entry.url)?.queryParametersAll.isNotEmpty) ?? + false; + _resizeControllerIfNeeded(hasParams ? 5 : 4); + } + } + @override void dispose() { _tabController.removeListener(_onTabIndexChange); @@ -93,6 +119,15 @@ class _NetworkDetailState extends ConsumerState if (prev != next) _rebuildController(); }); + // Postman-style "Params" tab — only shown when the URL actually + // has a query string. Hidden otherwise to avoid a useless empty + // tab taking up real estate. + final hasParams = + (Uri.tryParse(entry.url)?.queryParametersAll.isNotEmpty) ?? false; + final tabLabels = hasParams + ? const ['Headers', 'Params', 'Request', 'Response', 'Timing'] + : const ['Headers', 'Request', 'Response', 'Timing']; + return Column( children: [ // URL bar + actions @@ -155,13 +190,20 @@ class _NetworkDetailState extends ConsumerState ], Expanded( child: Tooltip( - message: entry.url, + message: (() { + try { + return Uri.decodeFull(entry.url); + } catch (_) { + return entry.url; + } + })(), waitDuration: const Duration(milliseconds: 300), child: TextComponent( - entry.url, + formatUrlPretty(entry.url), style: TextStyle( fontFamily: AppConstants.monoFontFamily, fontSize: 11, + height: 1.35, color: isDark ? ColorTokens.lightBackground : Colors.black87, @@ -179,14 +221,21 @@ class _NetworkDetailState extends ConsumerState children: [ if (entry.duration != null) ...[ TimingBar(duration: entry.duration!), + if (NetworkVia.isKnown(entry.via)) ...[ + const SizedBox(width: 10), + _ViaTag(via: entry.via), + ], const Spacer(), - ] else + ] else ...[ + if (NetworkVia.isKnown(entry.via)) + _ViaTag(via: entry.via), const Spacer(), + ], CopyButton( tooltip: 'Copy URL', icon: LucideIcons.link, - onTap: () => - _copyText(context, entry.url, 'URL'), + onTap: () => _copyText( + context, formatUrlOneLine(entry.url), 'URL'), ), const SizedBox(width: 4), CopyButton( @@ -249,7 +298,7 @@ class _NetworkDetailState extends ConsumerState controller: _tabController, isDark: isDark, accentColor: ColorTokens.primary, - tabs: const ['Headers', 'Request', 'Response', 'Timing'], + tabs: tabLabels, ), Expanded( child: TabBarView( @@ -260,9 +309,15 @@ class _NetworkDetailState extends ConsumerState index: 0, builder: (_) => HeadersView(entry: entry), ), + if (hasParams) + LazyTab( + controller: _tabController, + index: 1, + builder: (_) => ParamsTab(uri: Uri.parse(entry.url)), + ), LazyTab( controller: _tabController, - index: 1, + index: hasParams ? 2 : 1, builder: (_) => BodyView( body: entry.requestBody, label: 'Request Body', @@ -272,7 +327,7 @@ class _NetworkDetailState extends ConsumerState ), LazyTab( controller: _tabController, - index: 2, + index: hasParams ? 3 : 2, builder: (_) => BodyView( body: entry.responseBody, label: 'Response Body', @@ -282,7 +337,7 @@ class _NetworkDetailState extends ConsumerState ), LazyTab( controller: _tabController, - index: 3, + index: hasParams ? 4 : 3, builder: (_) => TimingView(entry: entry), ), ], @@ -360,4 +415,53 @@ class TimingBar extends StatelessWidget { void _copyText(BuildContext context, String text, String label) { Clipboard.setData(ClipboardData(text: text)); showCopiedToast(context, label: '$label copied'); +} + +/// Compact tag rendered in the action row, right after the [TimingBar]. +/// Lower visual weight than the row-1 [HttpMethodBadge] / [StatusBadge] +/// so it doesn't compete for attention with the URL or status. +class _ViaTag extends StatelessWidget { + final String via; + + const _ViaTag({required this.via}); + + @override + Widget build(BuildContext context) { + final Color color; + final String label; + switch (via) { + case NetworkVia.fetch: + color = ColorTokens.info; + label = 'FETCH'; + break; + case NetworkVia.xhr: + color = ColorTokens.warning; + label = 'XHR'; + break; + default: + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + border: Border.all( + color: color.withValues(alpha: 0.25), + width: 1, + ), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + label, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 9, + fontWeight: FontWeight.w700, + color: color.withValues(alpha: 0.9), + letterSpacing: 0.3, + ), + ), + ); + } } \ No newline at end of file diff --git a/lib/features/all_events/presentation/event_row/event_row.dart b/lib/features/all_events/presentation/event_row/event_row.dart index a4edf07..3445b12 100644 --- a/lib/features/all_events/presentation/event_row/event_row.dart +++ b/lib/features/all_events/presentation/event_row/event_row.dart @@ -141,6 +141,14 @@ class EventRow extends StatelessWidget { ], ), ), + // Via badge (fetch / xhr) — only for network events that + // reported which interceptor path handled the call. + if (event.type == EventType.network && + event.rawData is NetworkEntry) ...[ + _ViaBadge( + via: (event.rawData as NetworkEntry).via), + const SizedBox(width: 4), + ], const SizedBox(width: 8), // Platform badge if (platform != null) ...[ @@ -149,19 +157,23 @@ class EventRow extends StatelessWidget { ], // Title Expanded( - child: Text( - event.title, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - color: event.level == 'error' - ? ColorTokens.error - : isDark - ? ColorTokens.lightBackground - : ColorTokens.darkNeutral, + child: Tooltip( + message: _tooltipFor(event), + waitDuration: const Duration(milliseconds: 300), + child: Text( + event.title, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + color: event.level == 'error' + ? ColorTokens.error + : isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), @@ -306,4 +318,67 @@ class EventRow extends StatelessWidget { return ColorTokens.logInfo; } } + + /// Tooltip text shown on hover of the row title. For network events + /// the user sees the full URL (URI-decoded so `%2C` becomes `,` etc.); + /// for other event types the title itself is informative enough. + static String _tooltipFor(UnifiedEvent event) { + if (event.type == EventType.network && + event.rawData is NetworkEntry) { + final raw = (event.rawData as NetworkEntry).url; + try { + return Uri.decodeFull(raw); + } catch (_) { + return raw; + } + } + return event.title; + } +} + +/// Compact pill rendered next to the type badge on network event rows. +/// Shows `FETCH` / `XHR` so the user can see which interceptor path +/// reported the call. Hidden when `via` is unknown (older clients or +/// other platforms). +class _ViaBadge extends StatelessWidget { + final String via; + + const _ViaBadge({required this.via}); + + @override + Widget build(BuildContext context) { + Color? color; + String? label; + switch (via) { + case NetworkVia.fetch: + color = ColorTokens.info; + label = 'FETCH'; + break; + case NetworkVia.xhr: + color = ColorTokens.warning; + label = 'XHR'; + break; + } + if (label == null) return const SizedBox.shrink(); + + return Container( + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 8), + alignment: Alignment.center, + decoration: BoxDecoration( + color: color!.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 9, + fontWeight: FontWeight.w800, + color: color, + letterSpacing: 0.3, + ), + ), + ); + } } \ No newline at end of file diff --git a/lib/features/all_events/presentation/header/header_bar.dart b/lib/features/all_events/presentation/header/header_bar.dart index d0a8d1c..acd0c7d 100644 --- a/lib/features/all_events/presentation/header/header_bar.dart +++ b/lib/features/all_events/presentation/header/header_bar.dart @@ -4,6 +4,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../../components/inputs/search_field.dart'; import '../../../../core/constants/app_constants.dart'; +import '../../../../core/providers/retention_provider.dart'; import '../../../../core/theme/color_tokens.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../models/device_info.dart'; @@ -23,6 +24,11 @@ import '../status/server_status_pill.dart'; /// single rounded container. class Header extends ConsumerWidget { final ValueNotifier eventCount; + /// Count BEFORE the display-limit trim. When this is greater than + /// [eventCount], the header renders a "Showing N of M" hint so users + /// know entries are being hidden (not deleted) by the All Events + /// display cap. + final ValueNotifier untrimmedCount; final bool serverRunning; final int port; final int deviceCount; @@ -40,6 +46,7 @@ class Header extends ConsumerWidget { const Header({ super.key, required this.eventCount, + required this.untrimmedCount, required this.serverRunning, required this.port, required this.deviceCount, @@ -63,6 +70,14 @@ class Header extends ConsumerWidget { // already in use") AND is currently not running. final startError = ref.watch(serverStartErrorProvider); final portOccupied = !serverRunning && startError != null; + // When a retention cap is set, show "current / cap" so the user can + // Show "current / cap" whenever the aggregated All Events list is + // capped (display-only filter or retention, whichever is active). + // Counts against [allEventsDisplayLimitProvider] first because the + // page header counts what's actually drawn. + final displayPreset = ref.watch(allEventsDisplayLimitProvider); + final displayLimit = displayPreset.limit; + final displayLabel = displayPreset.label; return Container( height: 48, @@ -79,23 +94,53 @@ class Header extends ConsumerWidget { const SizedBox(width: 8), ValueListenableBuilder( valueListenable: eventCount, - builder: (_, count, __) => Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '$count', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: AppConstants.monoFontFamily, - color: isDark ? Colors.grey[400] : Colors.grey[600], - ), - ), + builder: (_, count, __) => ValueListenableBuilder( + valueListenable: untrimmedCount, + builder: (_, untrimmed, __) { + final isTrimmed = displayLimit != null && untrimmed > count; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + displayLimit == null + ? '$count' + : '$count / $displayLabel', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: AppConstants.monoFontFamily, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + ), + if (isTrimmed) ...[ + const SizedBox(height: 2), + Text( + // Sort order is oldestFirst (set in + // all_events_provider.dart), so `untrimmed - + // count` is exactly the number of oldest + // entries being hidden by the cap. + 'Showing $count of $untrimmed', + style: TextStyle( + fontSize: 9, + color: isDark ? Colors.grey[600] : Colors.grey[500], + fontFamily: AppConstants.monoFontFamily, + ), + ), + ], + ], + ); + }, ), ), const SizedBox(width: 14), diff --git a/lib/features/all_events/presentation/pages/all_events_page.dart b/lib/features/all_events/presentation/pages/all_events_page.dart index 33f4477..ca8de35 100644 --- a/lib/features/all_events/presentation/pages/all_events_page.dart +++ b/lib/features/all_events/presentation/pages/all_events_page.dart @@ -4,12 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../../components/feedback/empty_state.dart'; -import '../../../../components/lists/stable_list_view.dart'; import '../../../../components/misc/jump_to_latest_fab.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../core/utils/smooth_scroll_controller.dart'; import '../../../../core/utils/toast_utils.dart'; import '../../../../l10n/app_localizations.dart'; +import '../../../../models/device_info.dart'; import '../../../../server/providers/server_providers.dart'; import '../../../benchmark/provider/benchmark_providers.dart'; import '../../../console/provider/console_providers.dart'; @@ -47,10 +47,10 @@ class _AllEventsPageState extends ConsumerState { final _scrollController = SmoothScrollController(); final _selectedEventId = ValueNotifier(null); final _eventCount = ValueNotifier(0); + final _untrimmedCount = ValueNotifier(0); bool _autoScroll = true; bool _programmaticScroll = false; int _visibleCount = 0; - int _generation = 0; final List _events = []; @override @@ -60,10 +60,10 @@ class _AllEventsPageState extends ConsumerState { ref.listenManual( filteredAllEventsProvider, (previous, next) { - _events..clear()..addAll(next); - _eventCount.value = _events.length; - _visibleCount = _events.length; - _generation++; + _events..clear()..addAll(next.items); + _eventCount.value = next.items.length; + _visibleCount = next.items.length; + _untrimmedCount.value = next.total; setState(() {}); if (_autoScroll) _autoScrollIfNeeded(); }, @@ -77,6 +77,7 @@ class _AllEventsPageState extends ConsumerState { _scrollController.dispose(); _selectedEventId.dispose(); _eventCount.dispose(); + _untrimmedCount.dispose(); super.dispose(); } @@ -177,6 +178,7 @@ class _AllEventsPageState extends ConsumerState { _selectedEventId.value = null; _events.clear(); _eventCount.value = 0; + _untrimmedCount.value = 0; _visibleCount = 0; setState(() {}); } @@ -350,6 +352,14 @@ class _AllEventsPageState extends ConsumerState { final sortOrder = ref.watch(allEventsSortOrderProvider); + // Pre-index devices by id so each EventRow can resolve its device + // in O(1). Without this, every row did `devices.where(...)` which + // was O(M) per row × N rows = O(N×M) per rebuild — visible jank + // around 500+ events with several connected devices. + final deviceById = { + for (final d in devices) d.deviceId: d, + }; + return Stack( children: [ Column( @@ -357,6 +367,7 @@ class _AllEventsPageState extends ConsumerState { // ── Header ── Header( eventCount: _eventCount, + untrimmedCount: _untrimmedCount, serverRunning: server.isRunning, port: server.isRunning ? server.port : 9090, deviceCount: devices.length, @@ -398,18 +409,8 @@ class _AllEventsPageState extends ConsumerState { child: ListView.custom( controller: _scrollController, itemExtent: 44, - childrenDelegate: StableBuilderDelegate( - generation: _generation, - childCount: _visibleCount, - findChildIndexCallback: (key) { - if (key is ValueKey) { - final idx = _events - .indexWhere((e) => e.id == key.value); - return idx == -1 ? null : idx; - } - return null; - }, - builder: (context, index) { + childrenDelegate: SliverChildBuilderDelegate( + (context, index) { final actualIndex = sortOrder == SortOrder.newestFirst ? _visibleCount - 1 - index @@ -419,10 +420,7 @@ class _AllEventsPageState extends ConsumerState { return const SizedBox.shrink(); } final event = _events[actualIndex]; - final device = devices - .where((d) => - d.deviceId == event.deviceId) - .firstOrNull; + final device = deviceById[event.deviceId]; return RepaintBoundary( key: ValueKey(event.id), child: ValueListenableBuilder( @@ -456,6 +454,7 @@ class _AllEventsPageState extends ConsumerState { ), ); }, + childCount: _visibleCount, ), ), ), diff --git a/lib/features/all_events/presentation/shared/params_tab.dart b/lib/features/all_events/presentation/shared/params_tab.dart new file mode 100644 index 0000000..db5d10e --- /dev/null +++ b/lib/features/all_events/presentation/shared/params_tab.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../components/feedback/empty_state.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/utils/toast_utils.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +/// Tab content for the URL query parameters. Each parameter is its own +/// cell: an uppercase label on top (the key) and the value rendered +/// below in monospace. Copy button appears on hover so the row chrome +/// stays calm by default. +class ParamsTab extends StatelessWidget { + final Uri uri; + + const ParamsTab({super.key, required this.uri}); + + @override + Widget build(BuildContext context) { + final params = uri.queryParametersAll; + if (params.isEmpty) { + return Center( + child: EmptyState( + icon: LucideIcons.list, + title: 'No params', + ), + ); + } + + final isDark = Theme.of(context).brightness == Brightness.dark; + final entries = params.entries.toList(); + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + itemCount: entries.length, + itemBuilder: (context, i) { + final e = entries[i]; + // queryParametersAll keeps the value as a list when the same + // key appears more than once (`?a=1&a=2`). Render that on one + // line so the cell doesn't grow unexpectedly. + final value = e.value.join(', '); + return _ParamCell( + keyName: e.key, + value: value, + isDark: isDark, + ); + }, + ); + } +} + +/// One cell in the params list. Vertical layout: key (small label) on +/// top, value (mono, full) below. Border between cells is the only +/// divider — no card background, keeping density high so the user +/// can scan many params without losing context. +class _ParamCell extends StatefulWidget { + final String keyName; + final String value; + final bool isDark; + + const _ParamCell({ + required this.keyName, + required this.value, + required this.isDark, + }); + + @override + State<_ParamCell> createState() => _ParamCellState(); +} + +class _ParamCellState extends State<_ParamCell> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final muted = widget.isDark + ? ColorTokens.lightBackground.withValues(alpha: 0.4) + : Colors.black45; + final fg = widget.isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral; + final divider = widget.isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.05); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: divider, width: 1), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Vertical stack: key label (top), value (bottom) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Key — small uppercase label, color-coded so the + // eye can latch onto the "what" before the value. + Text( + widget.keyName.toUpperCase(), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 10, + color: ColorTokens.info, + letterSpacing: 0.6, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + // Value — selectable so the user can drag-copy part + // of a long value without taking the whole thing. + SelectableText( + widget.value, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + color: fg, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Copy button — appears on hover, stays out of the way + // otherwise. Aligns to the top so a tall value cell still + // puts the action near the key label. + AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _hovered ? 1.0 : 0.0, + child: Tooltip( + message: 'Copy ${widget.keyName}', + waitDuration: const Duration(milliseconds: 300), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData( + text: '${widget.keyName}=${widget.value}')); + showCopiedToast(context, label: 'Param copied'); + }, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.all(4), + child: Icon( + LucideIcons.copy, + size: 12, + color: muted, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/all_events/provider/all_events_provider.dart b/lib/features/all_events/provider/all_events_provider.dart index 84bd7cf..462b52f 100644 --- a/lib/features/all_events/provider/all_events_provider.dart +++ b/lib/features/all_events/provider/all_events_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/retention_provider.dart'; import '../../../core/providers/tab_visibility_provider.dart'; import '../../../core/utils/duration_format.dart'; import '../../../core/utils/log_message_summary.dart'; @@ -238,14 +239,28 @@ final allEventsSortOrderProvider = StateProvider( /// Whether to show system/connectivity check URLs final showSystemUrlsProvider = StateProvider((ref) => false); -final filteredAllEventsProvider = Provider>((ref) { +/// Result of filtering All Events. [items] is the (possibly trimmed) +/// list shown in the page; [total] is the count BEFORE the display +/// trim — used by the header to render "Showing N of M" hints when +/// the cap is active. +class FilteredAllEventsResult { + final List items; + final int total; + + const FilteredAllEventsResult({required this.items, required this.total}); +} + +final filteredAllEventsProvider = + Provider((ref) { final events = ref.watch(allEventsProvider); final search = ref.watch(allEventsSearchProvider).toLowerCase(); final filters = ref.watch(allEventsFilterProvider); final errorsOnly = ref.watch(allEventsErrorsOnlyProvider); final selectedDevice = ref.watch(selectedDeviceProvider); + final displayLimit = + ref.watch(allEventsDisplayLimitProvider.select((p) => p.limit)); - return events.where((e) { + final filtered = events.where((e) { if (selectedDevice == null) return false; if (selectedDevice != allDevicesValue && e.deviceId != selectedDevice) return false; if (!filters.contains(e.type)) return false; @@ -256,6 +271,21 @@ final filteredAllEventsProvider = Provider>((ref) { } return true; }).toList(); + + // View-only cap — keeps the most recent N entries. Source providers + // are untouched, so toggling the setting back to Unlimited restores + // every entry. `total` stays at the pre-trim count so the header can + // surface the "Showing N of M" hint. + if (displayLimit != null && filtered.length > displayLimit) { + return FilteredAllEventsResult( + items: filtered.sublist(filtered.length - displayLimit), + total: filtered.length, + ); + } + return FilteredAllEventsResult( + items: filtered, + total: filtered.length, + ); }); String _shortenUrl(String url) { diff --git a/lib/features/benchmark/provider/benchmark_providers.dart b/lib/features/benchmark/provider/benchmark_providers.dart index fda30c4..692cdea 100644 --- a/lib/features/benchmark/provider/benchmark_providers.dart +++ b/lib/features/benchmark/provider/benchmark_providers.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/retention_provider.dart'; +import '../../../core/utils/list_retention.dart'; +import '../../../core/utils/retention_capped.dart'; import '../../../models/log/benchmark_entry.dart'; import '../../../server/providers/server_providers.dart'; import '../../../server/ws_message_handler.dart'; @@ -9,14 +12,35 @@ import '../../../server/ws_message_handler.dart'; final benchmarkEntriesProvider = StateNotifierProvider>((ref) { final handler = ref.watch(wsMessageHandlerProvider); - final notifier = BenchmarkNotifier(handler); + final notifier = BenchmarkNotifier(handler, ref); ref.onDispose(() => notifier.cancelSubscription()); return notifier; }); +/// Total benchmark entries ever received by [BenchmarkNotifier], +/// including ones dropped by the retention cap. +/// +/// Watches [benchmarkEntriesProvider] (not just the notifier) so this +/// rebuilds every time a new entry is appended — the notifier's +/// [BenchmarkNotifier.totalSeen] getter is otherwise non-reactive. +final benchmarkTotalSeenProvider = Provider((ref) { + ref.watch(benchmarkEntriesProvider); // subscribe to state changes + return ref.read(benchmarkEntriesProvider.notifier).totalSeen; +}); + +/// Source-cached list (capped to the user's retention limit) plus the +/// lifetime total (including dropped entries). +final benchmarkDisplayProvider = + Provider>((ref) { + final all = ref.watch(benchmarkEntriesProvider); + final limit = ref.watch(retentionLimitProvider.select((p) => p.limit)); + final totalSeen = ref.watch(benchmarkTotalSeenProvider); + return applyRetentionCap(all, limit, totalSeen: totalSeen); +}); + final filteredBenchmarkEntriesProvider = Provider>((ref) { - final entries = ref.watch(benchmarkEntriesProvider); + final entries = ref.watch(benchmarkDisplayProvider).items; final selectedDevice = ref.watch(selectedDeviceProvider); final search = ref.watch(benchmarkSearchProvider).toLowerCase(); @@ -74,8 +98,13 @@ class BenchmarkStats { class BenchmarkNotifier extends StateNotifier> { late final StreamSubscription> _sub; + final Ref _ref; + + /// Total benchmark entries ever received, including ones dropped by the cap. + int _totalSeen = 0; + int get totalSeen => _totalSeen; - BenchmarkNotifier(WsMessageHandler handler) : super([]) { + BenchmarkNotifier(WsMessageHandler handler, this._ref) : super([]) { _sub = handler.onBenchmark.listen((data) { final steps = (data['steps'] as List?) ?.map((s) => BenchmarkStep( @@ -97,11 +126,9 @@ class BenchmarkNotifier extends StateNotifier> { steps: steps, ); - if (state.length > 5000) { - state = [...state.skip(500), entry]; - } else { - state = [...state, entry]; - } + final limit = _ref.read(retentionLimitProvider).limit ?? kRetentionSafetyCap; + state = truncateList([...state, entry], limit); + _totalSeen++; }); } diff --git a/lib/features/console/presentation/header/toolbar.dart b/lib/features/console/presentation/header/toolbar.dart index 088c74c..720b2df 100644 --- a/lib/features/console/presentation/header/toolbar.dart +++ b/lib/features/console/presentation/header/toolbar.dart @@ -3,12 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../../components/inputs/search_field.dart'; +import '../../../../components/misc/retention_hint.dart'; import '../../../../core/theme/color_tokens.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../models/log/log_entry.dart'; +import '../../../../core/providers/retention_provider.dart'; import '../../provider/console_providers.dart'; -import '../shared/count_pill.dart'; import '../shared/level_color.dart'; import 'icon_btn.dart'; import 'level_filter_chip.dart'; @@ -35,6 +36,10 @@ class Toolbar extends ConsumerWidget { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final activeFilters = ref.watch(consoleFilterProvider); + final retentionPreset = ref.watch(retentionLimitProvider); + final retentionLimit = retentionPreset.limit; + final retentionLabel = retentionPreset.label; + final capped = ref.watch(consoleDisplayProvider); return Container( height: 48, @@ -51,7 +56,14 @@ class Toolbar extends ConsumerWidget { const SizedBox(width: 8), ValueListenableBuilder( valueListenable: entryCount, - builder: (_, count, _) => CountPill(count: count), + builder: (_, count, _) { + return RetentionHint( + count: count, + total: capped.total, + limit: retentionLimit, + limitLabel: retentionLabel, + ); + }, ), const SizedBox(width: 16), diff --git a/lib/features/console/presentation/shared/count_pill.dart b/lib/features/console/presentation/shared/count_pill.dart index a4ebf23..e6b3085 100644 --- a/lib/features/console/presentation/shared/count_pill.dart +++ b/lib/features/console/presentation/shared/count_pill.dart @@ -6,9 +6,9 @@ import '../../../../core/constants/app_constants.dart'; /// colored count pill in error_inspector). Local copy — color/alpha /// palette differs from `error_inspector/shared/count_pill.dart`. class CountPill extends StatelessWidget { - final int count; + final String text; - const CountPill({super.key, required this.count}); + const CountPill({super.key, required this.text}); @override Widget build(BuildContext context) { @@ -23,7 +23,7 @@ class CountPill extends StatelessWidget { borderRadius: BorderRadius.circular(10), ), child: Text( - '$count', + text, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, diff --git a/lib/features/console/provider/console_providers.dart b/lib/features/console/provider/console_providers.dart index ded046d..5f0815e 100644 --- a/lib/features/console/provider/console_providers.dart +++ b/lib/features/console/provider/console_providers.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/retention_provider.dart'; +import '../../../core/utils/list_retention.dart'; +import '../../../core/utils/retention_capped.dart'; import '../../../models/log/log_entry.dart'; import '../../../server/providers/server_providers.dart'; import '../../../server/ws_message_handler.dart'; @@ -9,18 +12,41 @@ import '../../../server/ws_message_handler.dart'; final consoleEntriesProvider = StateNotifierProvider>((ref) { final handler = ref.watch(wsMessageHandlerProvider); - final notifier = ConsoleNotifier(handler); + final notifier = ConsoleNotifier(handler, ref); ref.onDispose(() => notifier.cancelSubscription()); return notifier; }); +/// Total log entries ever received by [ConsoleNotifier], including +/// ones dropped by the retention cap. Drives the "Showing N of M" hint. +/// +/// Watches [consoleEntriesProvider] (not just the notifier) so this +/// rebuilds every time a new entry is appended — the notifier's +/// [ConsoleNotifier.totalSeen] getter is otherwise non-reactive. +final consoleTotalSeenProvider = Provider((ref) { + ref.watch(consoleEntriesProvider); // subscribe to state changes + return ref.read(consoleEntriesProvider.notifier).totalSeen; +}); + +/// Source-cached list (capped to the user's retention limit) plus the +/// lifetime total (including dropped entries). Toolbars consume this +/// so they can surface a "Showing N of M" note when entries were +/// dropped by the cap. +final consoleDisplayProvider = + Provider>((ref) { + final all = ref.watch(consoleEntriesProvider); + final limit = ref.watch(retentionLimitProvider.select((p) => p.limit)); + final totalSeen = ref.watch(consoleTotalSeenProvider); + return applyRetentionCap(all, limit, totalSeen: totalSeen); +}); + final consoleSearchProvider = StateProvider((ref) => ''); final consoleFilterProvider = StateProvider>( (ref) => LogLevel.values.toSet(), ); final filteredConsoleEntriesProvider = Provider>((ref) { - final entries = ref.watch(consoleEntriesProvider); + final entries = ref.watch(consoleDisplayProvider).items; final search = ref.watch(consoleSearchProvider).toLowerCase(); final filters = ref.watch(consoleFilterProvider); final selectedDevice = ref.watch(selectedDeviceProvider); @@ -40,14 +66,17 @@ final filteredConsoleEntriesProvider = Provider>((ref) { class ConsoleNotifier extends StateNotifier> { late final StreamSubscription _sub; + final Ref _ref; + + /// Total log entries ever received, including ones dropped by the cap. + int _totalSeen = 0; + int get totalSeen => _totalSeen; - ConsoleNotifier(WsMessageHandler wsMessageHandler) : super([]) { + ConsoleNotifier(WsMessageHandler wsMessageHandler, this._ref) : super([]) { _sub = wsMessageHandler.onLog.listen((entry) { - if (state.length > 10000) { - state = [...state.skip(1000), entry]; - } else { - state = [...state, entry]; - } + final limit = _ref.read(retentionLimitProvider).limit ?? kRetentionHighVolumeCap; + state = truncateList([...state, entry], limit); + _totalSeen++; }); } diff --git a/lib/features/display/provider/display_providers.dart b/lib/features/display/provider/display_providers.dart index ba6a0c5..0f05169 100644 --- a/lib/features/display/provider/display_providers.dart +++ b/lib/features/display/provider/display_providers.dart @@ -1,30 +1,64 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/retention_provider.dart'; +import '../../../core/utils/list_retention.dart'; +import '../../../core/utils/retention_capped.dart'; import '../../../models/display/display_entry.dart'; import '../../../server/providers/server_providers.dart'; +import '../../../server/ws_message_handler.dart'; // ---- Display Entries ---- final displayEntriesProvider = StateNotifierProvider>((ref) { final handler = ref.watch(wsMessageHandlerProvider); - final notifier = DisplayEntriesNotifier(); - final sub = handler.onDisplay.listen(notifier.add); - ref.onDispose(() => sub.cancel()); + final notifier = DisplayEntriesNotifier(handler, ref); + ref.onDispose(() => notifier.cancelSubscription()); return notifier; }); +/// Total display entries ever received by [DisplayEntriesNotifier], +/// including ones dropped by the retention cap. +/// +/// Watches [displayEntriesProvider] (not just the notifier) so this +/// rebuilds every time a new entry is appended — the notifier's +/// [DisplayEntriesNotifier.totalSeen] getter is otherwise non-reactive. +final displayTotalSeenProvider = Provider((ref) { + ref.watch(displayEntriesProvider); // subscribe to state changes + return ref.read(displayEntriesProvider.notifier).totalSeen; +}); + +/// Source-cached list (capped to the user's retention limit) plus the +/// lifetime total (including dropped entries). +final displayDisplayProvider = + Provider>((ref) { + final all = ref.watch(displayEntriesProvider); + final limit = ref.watch(retentionLimitProvider.select((p) => p.limit)); + final totalSeen = ref.watch(displayTotalSeenProvider); + return applyRetentionCap(all, limit, totalSeen: totalSeen); +}); + class DisplayEntriesNotifier extends StateNotifier> { - DisplayEntriesNotifier() : super([]); + late final StreamSubscription _sub; + final Ref _ref; + + /// Total display entries ever received, including ones dropped by the cap. + int _totalSeen = 0; + int get totalSeen => _totalSeen; + + DisplayEntriesNotifier(WsMessageHandler handler, this._ref) : super([]) { + _sub = handler.onDisplay.listen(add); + } void add(DisplayEntry entry) { - if (state.length >= 5000) { - state = [...state.sublist(state.length - 4000), entry]; - } else { - state = [...state, entry]; - } + final limit = _ref.read(retentionLimitProvider).limit ?? kRetentionSafetyCap; + state = truncateList([...state, entry], limit); + _totalSeen++; } + void cancelSubscription() => _sub.cancel(); void clear() => state = []; } @@ -34,22 +68,60 @@ final asyncOperationEntriesProvider = StateNotifierProvider>( (ref) { final handler = ref.watch(wsMessageHandlerProvider); - final notifier = AsyncOpEntriesNotifier(); - final sub = handler.onAsyncOperation.listen(notifier.add); - ref.onDispose(() => sub.cancel()); + final notifier = AsyncOpEntriesNotifier(handler, ref); + ref.onDispose(() => notifier.cancelSubscription()); return notifier; }); +/// Total async-op entries ever received by [AsyncOpEntriesNotifier], +/// including ones dropped by the retention cap. +/// +/// Watches [asyncOperationEntriesProvider] (not just the notifier) so this +/// rebuilds every time a new entry is appended — the notifier's +/// [AsyncOpEntriesNotifier.totalSeen] getter is otherwise non-reactive. +final asyncOpTotalSeenProvider = Provider((ref) { + ref.watch(asyncOperationEntriesProvider); // subscribe to state changes + return ref.read(asyncOperationEntriesProvider.notifier).totalSeen; +}); + +/// Source-cached list (capped to the user's retention limit) plus the +/// lifetime total (including dropped entries). +final asyncOpDisplayProvider = + Provider>((ref) { + final all = ref.watch(asyncOperationEntriesProvider); + final limit = ref.watch(retentionLimitProvider.select((p) => p.limit)); + final totalSeen = ref.watch(asyncOpTotalSeenProvider); + return applyRetentionCap(all, limit, totalSeen: totalSeen); +}); + +/// Async ops have a "drop resolved/rejected first" rule — the user cares +/// more about pending `start` rows (they're waiting on them) than +/// historical `resolve`/`reject` rows. The drop happens before the +/// straight FIFO trim so cap pressure never kills an in-flight op. class AsyncOpEntriesNotifier extends StateNotifier> { - AsyncOpEntriesNotifier() : super([]); + late final StreamSubscription _sub; + final Ref _ref; + + /// Total async-op entries ever received, including ones dropped by the cap. + int _totalSeen = 0; + int get totalSeen => _totalSeen; + + AsyncOpEntriesNotifier(WsMessageHandler handler, this._ref) : super([]) { + _sub = handler.onAsyncOperation.listen(add); + } void add(AsyncOperationEntry entry) { - if (state.length >= 5000) { - state = [...state.sublist(state.length - 4000), entry]; - } else { - state = [...state, entry]; - } + final limit = _ref.read(retentionLimitProvider).limit ?? kRetentionSafetyCap; + state = truncateList( + [...state, entry], + limit, + // `start` is the "pending" state — keep these in preference to + // completed (resolve) or failed (reject) entries when trimming. + shouldDrop: (e) => e.status != AsyncOperationStatus.start, + ); + _totalSeen++; } + void cancelSubscription() => _sub.cancel(); void clear() => state = []; -} +} \ No newline at end of file diff --git a/lib/features/error_inspector/presentation/header/toolbar.dart b/lib/features/error_inspector/presentation/header/toolbar.dart index 3230d1c..d0bc364 100644 --- a/lib/features/error_inspector/presentation/header/toolbar.dart +++ b/lib/features/error_inspector/presentation/header/toolbar.dart @@ -8,6 +8,7 @@ import '../../../../core/theme/color_tokens.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../models/log/error_event.dart'; +import '../../../../core/providers/retention_provider.dart'; import '../../provider/error_providers.dart'; import '../shared/count_up.dart'; import '../shared/error_tokens.dart' show platformColor, platformLabel; @@ -45,6 +46,10 @@ class Toolbar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isDark = Theme.of(context).brightness == Brightness.dark; final activeFilters = ref.watch(errorFilterProvider); + final retentionPreset = ref.watch(retentionLimitProvider); + final retentionLimit = retentionPreset.limit; + final retentionLabel = retentionPreset.label; + final capped = ref.watch(errorDisplayProvider); return Column( children: [ @@ -80,44 +85,66 @@ class Toolbar extends ConsumerWidget { ValueListenableBuilder( valueListenable: entryCount, builder: (context, count, _) { - return Row( + final isTrimmed = + retentionLimit != null && capped.total > count; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - if (count > 0) - Padding( - padding: const EdgeInsets.only(right: 4), - child: PulsingDot( - color: ColorTokens.logError, - size: 7, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (count > 0) + Padding( + padding: const EdgeInsets.only(right: 4), + child: PulsingDot( + color: ColorTokens.logError, + size: 7, + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: count > 0 + ? ColorTokens.logError.withValues(alpha: 0.12) + : (isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.04)), + borderRadius: BorderRadius.circular(10), + ), + child: CountUp( + value: count, + formatter: (n) => retentionLimit == null + ? '$n' + : '$n / $retentionLabel', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + fontFamily: AppConstants.monoFontFamily, + color: count > 0 + ? ColorTokens.logError + : (isDark + ? Colors.grey[400] + : Colors.grey[600]), + ), + ), ), - ), - AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: count > 0 - ? ColorTokens.logError.withValues(alpha: 0.12) - : (isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.04)), - borderRadius: BorderRadius.circular(10), - ), - child: CountUp( - value: count, + ], + ), + if (isTrimmed) ...[ + const SizedBox(height: 2), + Text( + 'Showing $count of ${capped.total}', style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, + fontSize: 9, fontFamily: AppConstants.monoFontFamily, - color: count > 0 - ? ColorTokens.logError - : (isDark - ? Colors.grey[400] - : Colors.grey[600]), + color: isDark ? Colors.grey[600] : Colors.grey[500], ), ), - ), + ], ], ); }, diff --git a/lib/features/error_inspector/provider/error_providers.dart b/lib/features/error_inspector/provider/error_providers.dart index e0c0eb2..71565ae 100644 --- a/lib/features/error_inspector/provider/error_providers.dart +++ b/lib/features/error_inspector/provider/error_providers.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/retention_provider.dart'; +import '../../../core/utils/list_retention.dart'; +import '../../../core/utils/retention_capped.dart'; import '../../../models/log/error_event.dart'; import '../../../server/providers/server_providers.dart'; import '../../../server/ws_message_handler.dart'; @@ -11,11 +14,32 @@ import '../../../server/ws_message_handler.dart'; final errorEntriesProvider = StateNotifierProvider>((ref) { final handler = ref.watch(wsMessageHandlerProvider); - final notifier = ErrorNotifier(handler); + final notifier = ErrorNotifier(handler, ref); ref.onDispose(() => notifier.cancelSubscription()); return notifier; }); +/// Total errors ever received by [ErrorNotifier], including ones +/// dropped by the retention cap. +/// +/// Watches [errorEntriesProvider] (not just the notifier) so this +/// rebuilds every time a new entry is appended — the notifier's +/// [ErrorNotifier.totalSeen] getter is otherwise non-reactive. +final errorTotalSeenProvider = Provider((ref) { + ref.watch(errorEntriesProvider); // subscribe to state changes + return ref.read(errorEntriesProvider.notifier).totalSeen; +}); + +/// Source-cached list (capped to the user's retention limit) plus the +/// lifetime total (including dropped entries). +final errorDisplayProvider = + Provider>((ref) { + final all = ref.watch(errorEntriesProvider); + final limit = ref.watch(retentionLimitProvider.select((p) => p.limit)); + final totalSeen = ref.watch(errorTotalSeenProvider); + return applyRetentionCap(all, limit, totalSeen: totalSeen); +}); + final errorSearchProvider = StateProvider((ref) => ''); final errorFilterProvider = StateProvider>( (ref) => ErrorPlatform.values.toSet(), @@ -88,14 +112,17 @@ final errorCountBySeverityProvider = Provider>((ref) { class ErrorNotifier extends StateNotifier> { late final StreamSubscription _sub; + final Ref _ref; + + /// Total errors ever received, including ones dropped by the cap. + int _totalSeen = 0; + int get totalSeen => _totalSeen; - ErrorNotifier(WsMessageHandler handler) : super([]) { + ErrorNotifier(WsMessageHandler handler, this._ref) : super([]) { _sub = handler.onError.listen((entry) { - if (state.length > 5000) { - state = [...state.skip(500), entry]; - } else { - state = [...state, entry]; - } + final limit = _ref.read(retentionLimitProvider).limit ?? kRetentionSafetyCap; + state = truncateList([...state, entry], limit); + _totalSeen++; }); } diff --git a/lib/features/network_inspector/presentation/pages/network_inspector_page.dart b/lib/features/network_inspector/presentation/pages/network_inspector_page.dart index f44297a..04b35d9 100644 --- a/lib/features/network_inspector/presentation/pages/network_inspector_page.dart +++ b/lib/features/network_inspector/presentation/pages/network_inspector_page.dart @@ -194,7 +194,6 @@ class _NetworkInspectorPageState ? const PositionRetainedScrollPhysics() : null, padding: const EdgeInsets.symmetric(vertical: 6), - itemExtent: 62, childrenDelegate: StableBuilderDelegate( generation: _generation, childCount: _visibleCount, diff --git a/lib/features/network_inspector/presentation/request/request_card.dart b/lib/features/network_inspector/presentation/request/request_card.dart index ea09047..41b5484 100644 --- a/lib/features/network_inspector/presentation/request/request_card.dart +++ b/lib/features/network_inspector/presentation/request/request_card.dart @@ -7,6 +7,7 @@ import '../../../../components/misc/status_badge.dart'; import '../../../../core/constants/app_constants.dart'; import '../../../../core/theme/color_tokens.dart'; import '../../../../core/utils/duration_format.dart'; +import '../../../../core/utils/network_url_formatter.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../server/providers/server_providers.dart'; import '../../../../models/network/network_entry.dart'; @@ -50,12 +51,31 @@ class RequestCard extends ConsumerWidget { try { uri = Uri.parse(entry.url); } catch (_) {} - final displayUrl = uri?.path ?? entry.url; - final host = uri?.host ?? ''; - final isRootPath = displayUrl == '/' || displayUrl.isEmpty; + final path = uri?.path ?? entry.url; + // Prefix scheme so the user can read the request at a glance — + // matches what curl-style tooling shows. + final host = uri == null + ? '' + : '${uri.scheme.isEmpty ? 'https' : uri.scheme}://${uri.host}'; + // Card title is the path (compact) — the full scheme+host lives + // in the badge row below, so we don't repeat it on the title. + final displayUrl = path; + final formatted = parseFormattedUrl(entry.url); + final isRootPath = path == '/' || path.isEmpty; final titleText = (entry.serviceAction != null && isRootPath) ? entry.serviceAction! : displayUrl; + // Compact query hint for the second line of the title block. Empty when + // the URL has no query string — the row stays a single line in that case. + final queryHint = formatted == null || formatted.queryParams.isEmpty + ? null + : formatted.queryParams + .take(2) + .map((p) => p.value.isEmpty ? p.key : '${p.key}=${p.value}') + .join(', ') + + (formatted.queryParams.length > 2 + ? ' + ${formatted.queryParams.length - 2}' + : ''); // Left bar color based on status code final Color leftBarColor; @@ -86,6 +106,22 @@ class RequestCard extends ConsumerWidget { sourceLabel = 'APP'; } + // Via badge (fetch / xhr) — distinguishes which interceptor path + // reported this entry. Hidden when unknown (older clients / other + // platforms). + Color? viaColor; + String? viaLabel; + switch (entry.via) { + case NetworkVia.fetch: + viaColor = ColorTokens.info; + viaLabel = 'FETCH'; + break; + case NetworkVia.xhr: + viaColor = ColorTokens.warning; + viaLabel = 'XHR'; + break; + } + return GestureDetector( onTap: onTap, child: MouseRegion( @@ -118,162 +154,207 @@ class RequestCard extends ConsumerWidget { width: 1, ), ), - child: IntrinsicHeight( - child: Row( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( children: [ // Left color bar - Container( + Positioned( + left: 0, + top: 0, + bottom: 0, width: 4, - decoration: BoxDecoration( + child: Container( color: leftBarColor, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(8), - bottomLeft: Radius.circular(8), - ), ), ), // Content - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - child: Row( - children: [ - // Badges row (compact) - if (device != null) ...[ - PlatformBadge(platform: device.platform), - const SizedBox(width: 4), - ], - HttpMethodBadge(method: entry.method), + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 12, 10), + child: Row( + children: [ + // Badges row (compact) + if (device != null) ...[ + PlatformBadge(platform: device.platform), const SizedBox(width: 4), - // URL + host - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ + ], + HttpMethodBadge(method: entry.method), + const SizedBox(width: 4), + // URL + host + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + titleText, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + fontWeight: FontWeight.w500, + color: (entry.isComplete && (entry.statusCode <= 0 || entry.statusCode >= 400)) + ? ColorTokens.error + : isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (queryHint != null) ...[ + const SizedBox(height: 4), Text( - titleText, + '? $queryHint', style: TextStyle( fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - fontWeight: FontWeight.w500, - color: (entry.isComplete && (entry.statusCode <= 0 || entry.statusCode >= 400)) - ? ColorTokens.error - : isDark - ? ColorTokens.lightBackground - : ColorTokens.darkNeutral, + fontSize: 10, + color: Colors.grey[500], + fontStyle: FontStyle.italic, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 2), - Row( - children: [ - if (entry.serviceName != null) ...[ - ServiceTag(name: entry.serviceName!), - const SizedBox(width: 4), - ], - // Source badge + ], + const SizedBox(height: 6), + Row( + children: [ + if (entry.serviceName != null) ...[ + ServiceTag(name: entry.serviceName!), + const SizedBox(width: 4), + ], + // Source badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: sourceColor.withValues(alpha: 0.10), + border: Border.all( + color: sourceColor.withValues(alpha: 0.22), + width: 1, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + sourceLabel, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: sourceColor, + letterSpacing: 0.3, + fontFamily: AppConstants.monoFontFamily, + ), + ), + ), + // Via badge (fetch / xhr path) — same + // chrome as source badge so the two + // read as siblings. + if (viaLabel != null) ...[ + const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric( - horizontal: 5, vertical: 1), + horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: sourceColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(3), + color: viaColor!.withValues(alpha: 0.10), + border: Border.all( + color: viaColor.withValues(alpha: 0.22), + width: 1, + ), + borderRadius: BorderRadius.circular(4), ), child: Text( - sourceLabel, + viaLabel, style: TextStyle( - fontSize: 8, + fontSize: 9, fontWeight: FontWeight.w700, - color: sourceColor, + color: viaColor, + letterSpacing: 0.3, + fontFamily: AppConstants.monoFontFamily, ), ), ), + ], + const SizedBox(width: 4), + if (entry.isComplete) + StatusBadge(statusCode: entry.statusCode) + else ...[ + SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: ColorTokens.warning, + ), + ), const SizedBox(width: 4), - if (entry.isComplete) - StatusBadge(statusCode: entry.statusCode) - else ...[ - SizedBox( - width: 10, - height: 10, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: ColorTokens.warning, - ), + Text( + S.of(context).inProgress, + style: TextStyle( + fontSize: 9, + color: ColorTokens.warning, + fontFamily: AppConstants.monoFontFamily, + fontWeight: FontWeight.w600, ), - const SizedBox(width: 4), - Text( - S.of(context).inProgress, + ), + ], + if (host.isNotEmpty) ...[ + const SizedBox(width: 6), + Flexible( + child: Text( + host, style: TextStyle( - fontSize: 9, - color: ColorTokens.warning, - fontFamily: AppConstants.monoFontFamily, - fontWeight: FontWeight.w600, + fontSize: 10, + color: Colors.grey[500], ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], - if (host.isNotEmpty) ...[ - const SizedBox(width: 6), - Flexible( - child: Text( - host, - style: TextStyle( - fontSize: 10, - color: Colors.grey[500], - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], + ), ], - ), - ], - ), + ], + ), + ], ), + ), - const SizedBox(width: 8), + const SizedBox(width: 8), - // Duration + timestamp - Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (entry.duration != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: _durationColor(entry.duration!) - .withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - formatDuration(entry.duration!), - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - fontWeight: FontWeight.w600, - color: _durationColor(entry.duration!), - ), - ), + // Duration + timestamp + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (entry.duration != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _durationColor(entry.duration!) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), ), - const SizedBox(height: 4), - Text( - time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 10, - color: Colors.grey[600], + child: Text( + formatDuration(entry.duration!), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + fontWeight: FontWeight.w600, + color: _durationColor(entry.duration!), + ), ), ), - ], - ), - ], - ), + const SizedBox(height: 4), + Text( + time, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 10, + color: Colors.grey[600], + ), + ), + ], + ), + ], ), ), ], diff --git a/lib/features/network_inspector/presentation/request/request_detail_panel.dart b/lib/features/network_inspector/presentation/request/request_detail_panel.dart index 6f65f01..61ad96b 100644 --- a/lib/features/network_inspector/presentation/request/request_detail_panel.dart +++ b/lib/features/network_inspector/presentation/request/request_detail_panel.dart @@ -18,6 +18,7 @@ import '../../../../core/theme/color_tokens.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../core/utils/code_generator.dart'; import '../../../../core/utils/duration_format.dart'; +import '../../../../core/utils/network_url_formatter.dart'; import '../../../../core/utils/screenshot_filename.dart'; import '../../../../core/utils/toast_utils.dart'; import '../../../../l10n/app_localizations.dart'; @@ -30,6 +31,7 @@ import '../shared/copy_action_chip.dart'; import '../shared/detail_tab_bar.dart'; import '../shared/detect_blob_payload.dart'; import '../shared/header_icon_button.dart'; +import '../shared/params_tab.dart'; /// Right-pane detail panel that swaps content based on the selected /// network request. Owns the screenshot machinery (full + per-tab) and @@ -88,6 +90,28 @@ class _RequestDetailPanelState extends ConsumerState final isDark = theme.brightness == Brightness.dark; final entry = widget.entry; + // Decide whether to surface the "Params" tab. The tab only exists + // when the URL has at least one `?key=value` segment — Postman-style + // gating so the tab list never shows a useless empty tab. + final hasParams = (Uri.tryParse(entry.url)?.queryParametersAll.isNotEmpty) ?? false; + final tabLabels = hasParams + ? const ['Headers', 'Params', 'Request', 'Response', 'Timing'] + : const ['Headers', 'Request', 'Response', 'Timing']; + + // Keep the controller in sync with the tab list length. Without + // this, switching from a param-less request to a param-heavy one + // (or vice versa) crashes the TabBarView. + if (_tabController.length != tabLabels.length) { + final oldIndex = _tabController.index.clamp(0, tabLabels.length - 1); + _tabController.dispose(); + _tabController = TabController( + length: tabLabels.length, + vsync: this, + animationDuration: ref.read(tabAnimationProvider), + initialIndex: oldIndex, + ); + } + ref.listen(tabAnimationProvider, (prev, next) { if (prev != next) _rebuildController(); }); @@ -100,18 +124,18 @@ class _RequestDetailPanelState extends ConsumerState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Method + status + URL + screenshot buttons + close + // Row 1: method + status (left) and action buttons (right). + // Kept on its own line so the URL row below can take the + // full width without competing for horizontal space. Padding( - padding: - const EdgeInsets.only(left: 12, right: 4, top: 8, bottom: 4), + padding: const EdgeInsets.fromLTRB(12, 10, 4, 6), child: Row( children: [ HttpMethodBadge(method: entry.method), const SizedBox(width: 6), - if (entry.isComplete) ...[ - StatusBadge(statusCode: entry.statusCode), - const SizedBox(width: 8), - ] else ...[ + if (entry.isComplete) + StatusBadge(statusCode: entry.statusCode) + else Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), @@ -142,25 +166,7 @@ class _RequestDetailPanelState extends ConsumerState ], ), ), - const SizedBox(width: 8), - ], - Expanded( - child: Tooltip( - message: entry.url, - waitDuration: const Duration(milliseconds: 300), - child: TextComponent( - entry.url, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - color: isDark ? Colors.white : Colors.black87, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - const SizedBox(width: 4), + const Spacer(), // Screenshot buttons Tooltip( message: S.of(context).captureFullTooltip, @@ -192,12 +198,76 @@ class _RequestDetailPanelState extends ConsumerState ), ), - // Timing bar - if (entry.duration != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: TimingBar(duration: entry.duration!), + // Row 2: full URL on its own line, multi-line so a long + // URL stays readable. Selectable so the user can drag + // a substring (e.g. an ID) into the clipboard. + Padding( + padding: + const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: SelectableText( + _decoded(entry.url), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + color: isDark ? Colors.white : Colors.black87, + height: 1.4, + ), + maxLines: 2, ), + ), + + // Row 3: timing + via tag on a single meta line. Subtle + // border-bottom separates the header from the tab bar. + Container( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 8), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + width: 1, + ), + ), + ), + child: Row( + children: [ + if (entry.duration != null) ...[ + TimingBar(duration: entry.duration!), + ], + if (NetworkVia.isKnown(entry.via)) ...[ + const SizedBox(width: 8), + Builder(builder: (context) { + final viaColor = entry.via == NetworkVia.fetch + ? ColorTokens.info + : ColorTokens.warning; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: viaColor.withValues(alpha: 0.10), + border: Border.all( + color: viaColor.withValues(alpha: 0.22), + width: 1, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + entry.via.toUpperCase(), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + color: viaColor, + ), + ), + ); + }), + ], + ], + ), + ), const SizedBox(height: 6), @@ -210,7 +280,10 @@ class _RequestDetailPanelState extends ConsumerState icon: LucideIcons.link, label: S.of(context).copyUrl, onTap: () { - Clipboard.setData(ClipboardData(text: entry.url)); + // Decoded single-line URL: %2C → ',' so the + // pasted value is readable, but still a valid URL. + Clipboard.setData( + ClipboardData(text: formatUrlOneLine(entry.url))); _showCopied(S.of(context).urlCopied); }, ), @@ -276,14 +349,12 @@ class _RequestDetailPanelState extends ConsumerState ), ), - const SizedBox(height: 8), - // Tabs DetailTabBar( controller: _tabController, isDark: isDark, accentColor: ColorTokens.primary, - tabs: const ['Headers', 'Request', 'Response', 'Timing'], + tabs: tabLabels, ), ], ), @@ -301,9 +372,15 @@ class _RequestDetailPanelState extends ConsumerState index: 0, builder: (_) => HeadersTab(entry: entry), ), + if (hasParams) + LazyTab( + controller: _tabController, + index: 1, + builder: (_) => ParamsTab(uri: Uri.parse(entry.url)), + ), LazyTab( controller: _tabController, - index: 1, + index: hasParams ? 2 : 1, builder: (_) => BodyTab( body: entry.requestBody, label: 'Request', @@ -312,7 +389,7 @@ class _RequestDetailPanelState extends ConsumerState ), LazyTab( controller: _tabController, - index: 2, + index: hasParams ? 3 : 2, builder: (_) => BodyTab( body: entry.responseBody, label: 'Response', @@ -321,7 +398,7 @@ class _RequestDetailPanelState extends ConsumerState ), LazyTab( controller: _tabController, - index: 3, + index: hasParams ? 4 : 3, builder: (_) => TimingTab(entry: entry), ), ], @@ -694,6 +771,16 @@ class _RequestDetailPanelState extends ConsumerState } } + /// Decode percent-encoded characters so users see `,` instead of + /// `%2C` etc. — matches the All Events detail panel. + String _decoded(String url) { + try { + return Uri.decodeFull(url); + } catch (_) { + return url; + } + } + Widget _buildFullScreenshotWidget(bool isDark) { final entry = widget.entry; final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( @@ -743,10 +830,12 @@ class _RequestDetailPanelState extends ConsumerState ], ), const SizedBox(height: 6), - TextComponent(entry.url, + TextComponent( + _decoded(entry.url), style: TextStyle( fontFamily: AppConstants.monoFontFamily, fontSize: 11, + height: 1.35, color: (entry.isComplete && (entry.statusCode <= 0 || entry.statusCode >= 400)) @@ -754,7 +843,9 @@ class _RequestDetailPanelState extends ConsumerState : isDark ? ColorTokens.lightBackground : ColorTokens.darkNeutral, - )), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis), if (entry.duration != null) ...[ const SizedBox(height: 6), TimingBar(duration: entry.duration!), @@ -887,9 +978,11 @@ class _RequestDetailPanelState extends ConsumerState StatusBadge(statusCode: entry.statusCode), const SizedBox(width: 8), Expanded( - child: TextComponent(entry.url, + child: TextComponent( + _decoded(entry.url), style: TextStyle( fontFamily: AppConstants.monoFontFamily, + height: 1.35, fontSize: 11, color: isDark ? ColorTokens.lightBackground diff --git a/lib/features/network_inspector/presentation/shared/params_tab.dart b/lib/features/network_inspector/presentation/shared/params_tab.dart new file mode 100644 index 0000000..db5d10e --- /dev/null +++ b/lib/features/network_inspector/presentation/shared/params_tab.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../components/feedback/empty_state.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/utils/toast_utils.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +/// Tab content for the URL query parameters. Each parameter is its own +/// cell: an uppercase label on top (the key) and the value rendered +/// below in monospace. Copy button appears on hover so the row chrome +/// stays calm by default. +class ParamsTab extends StatelessWidget { + final Uri uri; + + const ParamsTab({super.key, required this.uri}); + + @override + Widget build(BuildContext context) { + final params = uri.queryParametersAll; + if (params.isEmpty) { + return Center( + child: EmptyState( + icon: LucideIcons.list, + title: 'No params', + ), + ); + } + + final isDark = Theme.of(context).brightness == Brightness.dark; + final entries = params.entries.toList(); + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + itemCount: entries.length, + itemBuilder: (context, i) { + final e = entries[i]; + // queryParametersAll keeps the value as a list when the same + // key appears more than once (`?a=1&a=2`). Render that on one + // line so the cell doesn't grow unexpectedly. + final value = e.value.join(', '); + return _ParamCell( + keyName: e.key, + value: value, + isDark: isDark, + ); + }, + ); + } +} + +/// One cell in the params list. Vertical layout: key (small label) on +/// top, value (mono, full) below. Border between cells is the only +/// divider — no card background, keeping density high so the user +/// can scan many params without losing context. +class _ParamCell extends StatefulWidget { + final String keyName; + final String value; + final bool isDark; + + const _ParamCell({ + required this.keyName, + required this.value, + required this.isDark, + }); + + @override + State<_ParamCell> createState() => _ParamCellState(); +} + +class _ParamCellState extends State<_ParamCell> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final muted = widget.isDark + ? ColorTokens.lightBackground.withValues(alpha: 0.4) + : Colors.black45; + final fg = widget.isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral; + final divider = widget.isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.05); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: divider, width: 1), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Vertical stack: key label (top), value (bottom) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Key — small uppercase label, color-coded so the + // eye can latch onto the "what" before the value. + Text( + widget.keyName.toUpperCase(), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 10, + color: ColorTokens.info, + letterSpacing: 0.6, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + // Value — selectable so the user can drag-copy part + // of a long value without taking the whole thing. + SelectableText( + widget.value, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + color: fg, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Copy button — appears on hover, stays out of the way + // otherwise. Aligns to the top so a tall value cell still + // puts the action near the key label. + AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _hovered ? 1.0 : 0.0, + child: Tooltip( + message: 'Copy ${widget.keyName}', + waitDuration: const Duration(milliseconds: 300), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData( + text: '${widget.keyName}=${widget.value}')); + showCopiedToast(context, label: 'Param copied'); + }, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.all(4), + child: Icon( + LucideIcons.copy, + size: 12, + color: muted, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/network_inspector/presentation/toolbar/toolbar.dart b/lib/features/network_inspector/presentation/toolbar/toolbar.dart index f917685..9b3c61f 100644 --- a/lib/features/network_inspector/presentation/toolbar/toolbar.dart +++ b/lib/features/network_inspector/presentation/toolbar/toolbar.dart @@ -3,9 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../../components/inputs/search_field.dart'; +import '../../../../components/misc/retention_hint.dart'; import '../../../../core/theme/color_tokens.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../l10n/app_localizations.dart'; +import '../../../../core/providers/retention_provider.dart'; import '../../provider/network_providers.dart'; import 'clear_stale_btn.dart'; import 'icon_btn.dart'; @@ -37,6 +39,10 @@ class Toolbar extends ConsumerWidget { final isDark = theme.brightness == Brightness.dark; final methodFilter = ref.watch(networkMethodFilterProvider); final sourceFilter = ref.watch(networkSourceFilterProvider); + final retentionPreset = ref.watch(retentionLimitProvider); + final retentionLimit = retentionPreset.limit; + final retentionLabel = retentionPreset.label; + final capped = ref.watch(networkDisplayProvider); return Container( height: 48, @@ -60,21 +66,14 @@ class Toolbar extends ConsumerWidget { const SizedBox(width: 8), ValueListenableBuilder( valueListenable: count, - builder: (_, c, _) => Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: ColorTokens.primary.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '$c', - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: ColorTokens.primary, - ), - ), - ), + builder: (_, c, _) { + return RetentionHint( + count: c, + total: capped.total, + limit: retentionLimit, + limitLabel: retentionLabel, + ); + }, ), const SizedBox(width: 16), diff --git a/lib/features/network_inspector/provider/network_providers.dart b/lib/features/network_inspector/provider/network_providers.dart index 5d4b388..c177c85 100644 --- a/lib/features/network_inspector/provider/network_providers.dart +++ b/lib/features/network_inspector/provider/network_providers.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/retention_provider.dart'; +import '../../../core/utils/list_retention.dart'; +import '../../../core/utils/retention_capped.dart'; import '../../../models/network/network_entry.dart'; import '../../../server/providers/server_providers.dart'; import '../../../server/ws_message_handler.dart'; @@ -9,11 +12,35 @@ import '../../../server/ws_message_handler.dart'; final networkEntriesProvider = StateNotifierProvider>((ref) { final handler = ref.watch(wsMessageHandlerProvider); - final notifier = NetworkNotifier(handler); + final notifier = NetworkNotifier(handler, ref); ref.onDispose(() => notifier.cancelSubscription()); return notifier; }); +/// Total entries ever received by [NetworkNotifier], including ones +/// dropped by the retention cap. Toolbars consume this so they can +/// surface a "Showing N of M" hint when entries were dropped. +/// +/// Watches [networkEntriesProvider] (not just the notifier) so this +/// rebuilds every time a new entry is appended — the notifier's +/// [NetworkNotifier.totalSeen] getter is otherwise non-reactive. +final networkTotalSeenProvider = Provider((ref) { + ref.watch(networkEntriesProvider); // subscribe to state changes + return ref.read(networkEntriesProvider.notifier).totalSeen; +}); + +/// Source-cached list (capped to the user's retention limit) plus the +/// lifetime total (including dropped entries). Toolbars consume this +/// so they can surface a "Showing N of M" hint when entries were +/// dropped by the cap. +final networkDisplayProvider = + Provider>((ref) { + final all = ref.watch(networkEntriesProvider); + final limit = ref.watch(retentionLimitProvider.select((p) => p.limit)); + final totalSeen = ref.watch(networkTotalSeenProvider); + return applyRetentionCap(all, limit, totalSeen: totalSeen); +}); + final networkSearchProvider = StateProvider((ref) => ''); final networkMethodFilterProvider = StateProvider((ref) => null); final networkSourceFilterProvider = @@ -37,7 +64,7 @@ bool _isSystemUrl(String url) { } final filteredNetworkEntriesProvider = Provider>((ref) { - final entries = ref.watch(networkEntriesProvider); + final entries = ref.watch(networkDisplayProvider).items; final search = ref.watch(networkSearchProvider).toLowerCase(); final methodFilter = ref.watch(networkMethodFilterProvider); final selectedDevice = ref.watch(selectedDeviceProvider); @@ -121,8 +148,15 @@ const Duration kStaleRequestThreshold = Duration(minutes: 10); class NetworkNotifier extends StateNotifier> { late final StreamSubscription _sub; + final Ref _ref; + + /// Total entries ever received, including ones dropped by the cap. + /// Used by the toolbar to surface a "Showing N of M" hint when the + /// cap has trimmed older entries. + int _totalSeen = 0; + int get totalSeen => _totalSeen; - NetworkNotifier(WsMessageHandler wsMessageHandler) : super([]) { + NetworkNotifier(WsMessageHandler wsMessageHandler, this._ref) : super([]) { _sub = wsMessageHandler.onNetwork.listen((entry) { if (entry.method.toUpperCase() == 'OPTIONS') return; if (entry.method.toUpperCase() == 'HEAD' && !entry.isComplete) return; @@ -136,11 +170,9 @@ class NetworkNotifier extends StateNotifier> { updated[index] = _mergeNetworkEntries(state[index], entry); state = updated; } else { - if (state.length > 5000) { - state = [...state.skip(500), entry]; - } else { - state = [...state, entry]; - } + final limit = _ref.read(retentionLimitProvider).limit ?? kRetentionSafetyCap; + state = truncateList([...state, entry], limit); + _totalSeen++; } }); } diff --git a/lib/features/performance/provider/performance_providers.dart b/lib/features/performance/provider/performance_providers.dart index dd3c10d..97bb9a0 100644 --- a/lib/features/performance/provider/performance_providers.dart +++ b/lib/features/performance/provider/performance_providers.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/retention_provider.dart'; +import '../../../core/utils/list_retention.dart'; +import '../../../core/utils/retention_capped.dart'; import '../../../models/network/network_entry.dart'; import '../../../models/performance/performance_entry.dart'; import '../../../server/providers/server_providers.dart'; @@ -13,11 +16,32 @@ import '../../network_inspector/provider/network_providers.dart'; final performanceEntriesProvider = StateNotifierProvider>((ref) { final handler = ref.watch(wsMessageHandlerProvider); - final notifier = PerformanceNotifier(handler); + final notifier = PerformanceNotifier(handler, ref); ref.onDispose(() => notifier.cancelSubscription()); return notifier; }); +/// Total performance entries ever received by [PerformanceNotifier], +/// including ones dropped by the retention cap. +/// +/// Watches [performanceEntriesProvider] (not just the notifier) so this +/// rebuilds every time a new entry is appended — the notifier's +/// [PerformanceNotifier.totalSeen] getter is otherwise non-reactive. +final performanceTotalSeenProvider = Provider((ref) { + ref.watch(performanceEntriesProvider); // subscribe to state changes + return ref.read(performanceEntriesProvider.notifier).totalSeen; +}); + +/// Source-cached list (capped to the user's retention limit) plus the +/// lifetime total (including dropped entries). +final performanceDisplayProvider = + Provider>((ref) { + final all = ref.watch(performanceEntriesProvider); + final limit = ref.watch(retentionLimitProvider.select((p) => p.limit)); + final totalSeen = ref.watch(performanceTotalSeenProvider); + return applyRetentionCap(all, limit, totalSeen: totalSeen); +}); + final filteredPerformanceEntriesProvider = Provider>((ref) { final entries = ref.watch(performanceEntriesProvider); @@ -335,14 +359,17 @@ final networkErrorRateProvider = Provider((ref) { class PerformanceNotifier extends StateNotifier> { late final StreamSubscription _sub; + final Ref _ref; + + /// Total performance entries ever received, including ones dropped by the cap. + int _totalSeen = 0; + int get totalSeen => _totalSeen; - PerformanceNotifier(WsMessageHandler handler) : super([]) { + PerformanceNotifier(WsMessageHandler handler, this._ref) : super([]) { _sub = handler.onPerformance.listen((entry) { - if (state.length > 10000) { - state = [...state.skip(1000), entry]; - } else { - state = [...state, entry]; - } + final limit = _ref.read(retentionLimitProvider).limit ?? kRetentionHighVolumeCap; + state = truncateList([...state, entry], limit); + _totalSeen++; }); } @@ -355,11 +382,32 @@ class PerformanceNotifier extends StateNotifier> { final memoryLeakEntriesProvider = StateNotifierProvider>((ref) { final handler = ref.watch(wsMessageHandlerProvider); - final notifier = MemoryLeakNotifier(handler); + final notifier = MemoryLeakNotifier(handler, ref); ref.onDispose(() => notifier.cancelSubscription()); return notifier; }); +/// Total memory-leak entries ever received by [MemoryLeakNotifier], +/// including ones dropped by the retention cap. +/// +/// Watches [memoryLeakEntriesProvider] (not just the notifier) so this +/// rebuilds every time a new entry is appended — the notifier's +/// [MemoryLeakNotifier.totalSeen] getter is otherwise non-reactive. +final memoryLeakTotalSeenProvider = Provider((ref) { + ref.watch(memoryLeakEntriesProvider); // subscribe to state changes + return ref.read(memoryLeakEntriesProvider.notifier).totalSeen; +}); + +/// Source-cached list (capped to the user's retention limit) plus the +/// lifetime total (including dropped entries). +final memoryLeakDisplayProvider = + Provider>((ref) { + final all = ref.watch(memoryLeakEntriesProvider); + final limit = ref.watch(retentionLimitProvider.select((p) => p.limit)); + final totalSeen = ref.watch(memoryLeakTotalSeenProvider); + return applyRetentionCap(all, limit, totalSeen: totalSeen); +}); + final memoryLeakFilterProvider = StateProvider((ref) => null); @@ -393,14 +441,17 @@ final memoryLeakCountsProvider = class MemoryLeakNotifier extends StateNotifier> { late final StreamSubscription _sub; + final Ref _ref; + + /// Total memory-leak entries ever received, including ones dropped by the cap. + int _totalSeen = 0; + int get totalSeen => _totalSeen; - MemoryLeakNotifier(WsMessageHandler handler) : super([]) { + MemoryLeakNotifier(WsMessageHandler handler, this._ref) : super([]) { _sub = handler.onMemoryLeak.listen((entry) { - if (state.length > 5000) { - state = [...state.skip(500), entry]; - } else { - state = [...state, entry]; - } + final limit = _ref.read(retentionLimitProvider).limit ?? kRetentionSafetyCap; + state = truncateList([...state, entry], limit); + _totalSeen++; }); } diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 3f6f126..811adfd 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -13,7 +13,9 @@ import '../../../../l10n/app_localizations.dart'; import '../../../../server/providers/server_providers.dart'; import '../header/card.dart'; import '../header/page_header.dart'; +import '../sections/all_events_display_section.dart'; import '../sections/appearance_section.dart'; +import '../sections/data_retention_section.dart'; import '../sections/device_history_section.dart'; import '../sections/detail_view_section.dart'; import '../sections/devices_section.dart'; @@ -223,6 +225,22 @@ class _SettingsPageState extends ConsumerState { ), const SizedBox(height: 16), + // Data Retention + SettingsCard( + surface: surface, + border: border, + child: const DataRetentionSection(), + ), + const SizedBox(height: 16), + + // All Events Display + SettingsCard( + surface: surface, + border: border, + child: const AllEventsDisplaySection(), + ), + const SizedBox(height: 16), + // USB Tools SettingsCard( surface: surface, diff --git a/lib/features/settings/presentation/sections/all_events_display_section.dart b/lib/features/settings/presentation/sections/all_events_display_section.dart new file mode 100644 index 0000000..9dfc211 --- /dev/null +++ b/lib/features/settings/presentation/sections/all_events_display_section.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/providers/retention_provider.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../header/section_title.dart'; +import '../shared/preset_dropdown.dart'; + +/// View-only filter for the All Events page. Caps the rendered list +/// to the N most-recent entries but never mutates the underlying +/// providers — flipping the setting back to Unlimited restores every +/// entry. +/// +/// Distinct from `DataRetentionSection` (which is destructive). +class AllEventsDisplaySection extends ConsumerWidget { + const AllEventsDisplaySection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final preset = ref.watch(allEventsDisplayLimitProvider); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle( + icon: LucideIcons.list, + title: S.of(context).allEventsDisplay, + ), + Text( + S.of(context).allEventsDisplayDesc, + style: TextStyle(fontSize: 11, color: Colors.grey[500], height: 1.4), + ), + const SizedBox(height: 14), + + // Visible entries + Row( + children: [ + SizedBox( + width: 100, + child: Text( + S.of(context).maxItems, + style: TextStyle(fontSize: 13, color: Colors.grey[500]), + ), + ), + Expanded( + child: PresetDropdown( + selected: preset, + onSelected: (p) => + ref.read(allEventsDisplayLimitProvider.notifier).set(p), + ), + ), + ], + ), + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.only(left: 100), + child: Text( + S.of(context).allEventsDisplayHelper, + style: TextStyle(fontSize: 10, color: Colors.grey[600], height: 1.4), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/settings/presentation/sections/data_retention_section.dart b/lib/features/settings/presentation/sections/data_retention_section.dart new file mode 100644 index 0000000..6c73e4e --- /dev/null +++ b/lib/features/settings/presentation/sections/data_retention_section.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/providers/retention_provider.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../header/section_title.dart'; +import '../shared/preset_dropdown.dart'; + +/// Hard data-retention cap. When the per-feature list exceeds this +/// limit, the oldest entries are dropped FIFO. Async ops keep their +/// pending `start` rows in preference to resolved/rejected ones. +/// +/// This is destructive — it actually mutates the underlying state, so +/// the related setting (`All Events Display Limit`) is view-only. +class DataRetentionSection extends ConsumerWidget { + const DataRetentionSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final preset = ref.watch(retentionLimitProvider); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle( + icon: LucideIcons.database, + title: S.of(context).dataRetention, + ), + Text( + S.of(context).dataRetentionDesc, + style: TextStyle(fontSize: 11, color: Colors.grey[500], height: 1.4), + ), + const SizedBox(height: 14), + + // Max items per list + Row( + children: [ + SizedBox( + width: 100, + child: Text( + S.of(context).maxItems, + style: TextStyle(fontSize: 13, color: Colors.grey[500]), + ), + ), + Expanded( + child: PresetDropdown( + selected: preset, + onSelected: (p) => + ref.read(retentionLimitProvider.notifier).set(p), + ), + ), + ], + ), + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.only(left: 100), + child: Text( + S.of(context).dataRetentionHelper, + style: TextStyle(fontSize: 10, color: Colors.grey[600], height: 1.4), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/settings/presentation/shared/preset_dropdown.dart b/lib/features/settings/presentation/shared/preset_dropdown.dart new file mode 100644 index 0000000..a6a311a --- /dev/null +++ b/lib/features/settings/presentation/shared/preset_dropdown.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/providers/retention_provider.dart'; + +/// Pill-style dropdown for picking a [RetentionPreset]. Shared between +/// `data_retention_section` (destructive cap) and +/// `all_events_display_section` (view-only filter). +/// +/// Mirrors the visual language of `language_dropdown.dart` — chevron +/// trigger row + popup menu with a check mark next to the active item. +class PresetDropdown extends StatelessWidget { + final RetentionPreset selected; + final ValueChanged onSelected; + + const PresetDropdown({ + super.key, + required this.selected, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(8), + ), + child: PopupMenuButton( + onSelected: onSelected, + offset: const Offset(0, 40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.08), + ), + ), + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + itemBuilder: (context) => RetentionPreset.values.map((preset) { + final isActive = preset == selected; + return PopupMenuItem( + value: preset, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Row( + children: [ + if (isActive) + const Icon(LucideIcons.check, + size: 14, color: Color(0xFF0D9488)) + else + const SizedBox(width: 14), + const SizedBox(width: 10), + Text( + preset.label, + style: TextStyle( + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + color: isActive + ? const Color(0xFF0D9488) + : (isDark ? Colors.grey[300] : Colors.grey[700]), + ), + ), + ], + ), + ); + }).toList(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + const Icon(LucideIcons.layers, size: 15, color: Color(0xFF6B7280)), + const SizedBox(width: 10), + Expanded( + child: Text( + selected.label, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.grey[200] : Colors.grey[800], + ), + ), + ), + const Icon(LucideIcons.chevronDown, + size: 14, color: Color(0xFF6B7280)), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/state_inspector/presentation/pages/state_inspector_page.dart b/lib/features/state_inspector/presentation/pages/state_inspector_page.dart index 2cc24f8..ad64fc4 100644 --- a/lib/features/state_inspector/presentation/pages/state_inspector_page.dart +++ b/lib/features/state_inspector/presentation/pages/state_inspector_page.dart @@ -18,6 +18,7 @@ import '../../../../components/lists/stable_list_view.dart'; import '../../../../components/misc/jump_to_latest_fab.dart'; import '../../../../core/utils/position_retained_scroll_physics.dart'; import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../core/providers/retention_provider.dart'; import '../../provider/state_providers.dart'; class StateInspectorPage extends ConsumerStatefulWidget { @@ -279,6 +280,11 @@ class _Toolbar extends ConsumerWidget { final scrollDir = ref.watch(scrollDirectionProvider); final isTop = scrollDir == ScrollDirection.top; + final retentionPreset = ref.watch(retentionLimitProvider); + final retentionLimit = retentionPreset.limit; + final retentionLabel = retentionPreset.label; + final capped = ref.watch(stateChangesDisplayProvider); + return Container( height: 48, padding: const EdgeInsets.symmetric(horizontal: 16), @@ -293,7 +299,31 @@ class _Toolbar extends ConsumerWidget { const SizedBox(width: 8), ValueListenableBuilder( valueListenable: totalCount, - builder: (_, c, __) => Text('$c changes', style: theme.textTheme.bodySmall), + builder: (_, c, __) { + final isTrimmed = + retentionLimit != null && capped.total > c; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + retentionLimit == null + ? '$c changes' + : '$c / $retentionLabel changes', + style: theme.textTheme.bodySmall, + ), + if (isTrimmed) + Text( + 'Showing $c of ${capped.total}', + style: TextStyle( + fontSize: 9, + fontFamily: AppConstants.monoFontFamily, + color: isDark ? Colors.grey[600] : Colors.grey[500], + ), + ), + ], + ); + }, ), const Spacer(), SizedBox( @@ -360,7 +390,6 @@ class _StateChangeTile extends ConsumerWidget { final VoidCallback onTap; const _StateChangeTile({ - super.key, required this.entry, required this.onTap, }); diff --git a/lib/features/state_inspector/provider/state_providers.dart b/lib/features/state_inspector/provider/state_providers.dart index c0c9486..75ec717 100644 --- a/lib/features/state_inspector/provider/state_providers.dart +++ b/lib/features/state_inspector/provider/state_providers.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/retention_provider.dart'; +import '../../../core/utils/list_retention.dart'; +import '../../../core/utils/retention_capped.dart'; import '../../../models/state/state_change.dart'; import '../../../server/providers/server_providers.dart'; import '../../../server/ws_message_handler.dart'; @@ -9,11 +12,32 @@ import '../../../server/ws_message_handler.dart'; final stateChangesProvider = StateNotifierProvider>((ref) { final handler = ref.watch(wsMessageHandlerProvider); - final notifier = StateChangesNotifier(handler); + final notifier = StateChangesNotifier(handler, ref); ref.onDispose(() => notifier.cancelSubscription()); return notifier; }); +/// Total state changes ever received by [StateChangesNotifier], +/// including ones dropped by the retention cap. +/// +/// Watches [stateChangesProvider] (not just the notifier) so this +/// rebuilds every time a new entry is appended — the notifier's +/// [StateChangesNotifier.totalSeen] getter is otherwise non-reactive. +final stateChangesTotalSeenProvider = Provider((ref) { + ref.watch(stateChangesProvider); // subscribe to state changes + return ref.read(stateChangesProvider.notifier).totalSeen; +}); + +/// Source-cached list (capped to the user's retention limit) plus the +/// lifetime total (including dropped entries). +final stateChangesDisplayProvider = + Provider>((ref) { + final all = ref.watch(stateChangesProvider); + final limit = ref.watch(retentionLimitProvider.select((p) => p.limit)); + final totalSeen = ref.watch(stateChangesTotalSeenProvider); + return applyRetentionCap(all, limit, totalSeen: totalSeen); +}); + final selectedStateChangeIdProvider = StateProvider((ref) => null); final selectedStateChangeProvider = Provider((ref) { @@ -26,7 +50,7 @@ final selectedStateChangeProvider = Provider((ref) { final stateSearchProvider = StateProvider((ref) => ''); final filteredStateChangesProvider = Provider>((ref) { - final entries = ref.watch(stateChangesProvider); + final entries = ref.watch(stateChangesDisplayProvider).items; final search = ref.watch(stateSearchProvider).toLowerCase(); final selectedDevice = ref.watch(selectedDeviceProvider); @@ -43,14 +67,17 @@ final filteredStateChangesProvider = Provider>((ref) { class StateChangesNotifier extends StateNotifier> { late final StreamSubscription _sub; + final Ref _ref; + + /// Total state changes ever received, including ones dropped by the cap. + int _totalSeen = 0; + int get totalSeen => _totalSeen; - StateChangesNotifier(WsMessageHandler wsMessageHandler) : super([]) { + StateChangesNotifier(WsMessageHandler wsMessageHandler, this._ref) : super([]) { _sub = wsMessageHandler.onState.listen((entry) { - if (state.length > 5000) { - state = [...state.skip(500), entry]; - } else { - state = [...state, entry]; - } + final limit = _ref.read(retentionLimitProvider).limit ?? kRetentionSafetyCap; + state = truncateList([...state, entry], limit); + _totalSeen++; }); } diff --git a/lib/features/storage_viewer/presentation/header/toolbar.dart b/lib/features/storage_viewer/presentation/header/toolbar.dart index 922f07d..8f1f9a8 100644 --- a/lib/features/storage_viewer/presentation/header/toolbar.dart +++ b/lib/features/storage_viewer/presentation/header/toolbar.dart @@ -3,10 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../../components/inputs/search_field.dart'; +import '../../../../components/misc/retention_hint.dart'; import '../../../../core/theme/color_tokens.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../models/storage/storage_entry.dart'; +import '../../../../core/providers/retention_provider.dart'; import '../../provider/storage_providers.dart'; import '../shared/storage_tokens.dart'; import 'icon_btn.dart'; @@ -35,6 +37,10 @@ class Toolbar extends ConsumerWidget { final isDark = theme.brightness == Brightness.dark; final opFilter = ref.watch(storageOperationFilterProvider); final typeFilter = ref.watch(storageTypeFilterProvider); + final retentionPreset = ref.watch(retentionLimitProvider); + final retentionLimit = retentionPreset.limit; + final retentionLabel = retentionPreset.label; + final capped = ref.watch(storageDisplayProvider); return Container( height: 48, @@ -58,21 +64,14 @@ class Toolbar extends ConsumerWidget { const SizedBox(width: 8), ValueListenableBuilder( valueListenable: totalCount, - builder: (_, c, _) => Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: ColorTokens.primary.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '$c', - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: ColorTokens.primary, - ), - ), - ), + builder: (_, c, _) { + return RetentionHint( + count: c, + total: capped.total, + limit: retentionLimit, + limitLabel: retentionLabel, + ); + }, ), const SizedBox(width: 16), diff --git a/lib/features/storage_viewer/provider/storage_providers.dart b/lib/features/storage_viewer/provider/storage_providers.dart index 56a4a4c..2f5acf6 100644 --- a/lib/features/storage_viewer/provider/storage_providers.dart +++ b/lib/features/storage_viewer/provider/storage_providers.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/retention_provider.dart'; +import '../../../core/utils/list_retention.dart'; +import '../../../core/utils/retention_capped.dart'; import '../../../models/storage/storage_entry.dart'; import '../../../server/providers/server_providers.dart'; import '../../../server/ws_message_handler.dart'; @@ -9,11 +12,32 @@ import '../../../server/ws_message_handler.dart'; final storageEntriesProvider = StateNotifierProvider>((ref) { final handler = ref.watch(wsMessageHandlerProvider); - final notifier = StorageNotifier(handler); + final notifier = StorageNotifier(handler, ref); ref.onDispose(() => notifier.cancelSubscription()); return notifier; }); +/// Total storage entries ever received by [StorageNotifier], +/// including ones dropped by the retention cap. +/// +/// Watches [storageEntriesProvider] (not just the notifier) so this +/// rebuilds every time a new entry is appended — the notifier's +/// [StorageNotifier.totalSeen] getter is otherwise non-reactive. +final storageTotalSeenProvider = Provider((ref) { + ref.watch(storageEntriesProvider); // subscribe to state changes + return ref.read(storageEntriesProvider.notifier).totalSeen; +}); + +/// Source-cached list (capped to the user's retention limit) plus the +/// lifetime total (including dropped entries). +final storageDisplayProvider = + Provider>((ref) { + final all = ref.watch(storageEntriesProvider); + final limit = ref.watch(retentionLimitProvider.select((p) => p.limit)); + final totalSeen = ref.watch(storageTotalSeenProvider); + return applyRetentionCap(all, limit, totalSeen: totalSeen); +}); + final storageSearchProvider = StateProvider((ref) => ''); /// Single-select operation filter (null = show all). @@ -25,7 +49,7 @@ final storageTypeFilterProvider = StateProvider>( ); final filteredStorageEntriesProvider = Provider>((ref) { - final entries = ref.watch(storageEntriesProvider); + final entries = ref.watch(storageDisplayProvider).items; final search = ref.watch(storageSearchProvider).toLowerCase(); final selectedDevice = ref.watch(selectedDeviceProvider); final opFilter = ref.watch(storageOperationFilterProvider); @@ -59,8 +83,13 @@ final selectedStorageEntryProvider = Provider((ref) { class StorageNotifier extends StateNotifier> { late final StreamSubscription _sub; + final Ref _ref; + + /// Total storage entries ever received, including ones dropped by the cap. + int _totalSeen = 0; + int get totalSeen => _totalSeen; - StorageNotifier(WsMessageHandler wsMessageHandler) : super([]) { + StorageNotifier(WsMessageHandler wsMessageHandler, this._ref) : super([]) { _sub = wsMessageHandler.onStorage.listen((entry) { // Update existing key or add new final index = state.indexWhere( @@ -70,12 +99,9 @@ class StorageNotifier extends StateNotifier> { updated[index] = entry; state = updated; } else { - // Cap at 5000 entries - if (state.length > 5000) { - state = [...state.skip(500), entry]; - } else { - state = [...state, entry]; - } + final limit = _ref.read(retentionLimitProvider).limit; + state = truncateList([...state, entry], limit); + _totalSeen++; } }); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e6c63db..b9eb07e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -119,6 +119,13 @@ "tabVisibilityDesc": "Toggle which tabs are visible. Disabled tabs show a lock icon and their data is excluded from All Events.", "detailView": "Detail View", "detailViewDesc": "Remembers how request/response bodies are shown and controls tab switching animation.", + "dataRetention": "Data Retention", + "dataRetentionDesc": "Hard cap applied to each per-feature list. When a list exceeds the limit, oldest entries are dropped FIFO. Pending async ops are preserved.", + "maxItems": "Max items", + "dataRetentionHelper": "Unlimited keeps every entry. Lower values free memory but lose history.", + "allEventsDisplay": "All Events Display", + "allEventsDisplayDesc": "View-only filter for the aggregated All Events list. The underlying logs are not deleted — flip back to Unlimited to restore.", + "allEventsDisplayHelper": "Lower values keep the most recent N entries visible at the cost of older history.", "bodyView": "Body view", "tabAnimation": "Tab animation", "tabAnimationDuration": "Duration", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fa7dda3..ebd8039 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -119,6 +119,13 @@ "tabVisibilityDesc": "Basculer la visibilité des onglets. Les onglets désactivés affichent une icône de verrouillage et leurs données sont exclues de Tous les événements.", "detailView": "Vue détaillée", "detailViewDesc": "Mémorise l'affichage des corps de requête/réponse et contrôle l'animation de changement d'onglet.", + "dataRetention": "Rétention des données", + "dataRetentionDesc": "Limite stricte appliquée à chaque liste par fonctionnalité. Lorsqu'une liste dépasse la limite, les entrées les plus anciennes sont supprimées en FIFO. Les opérations asynchrones en attente sont préservées.", + "maxItems": "Éléments max", + "dataRetentionHelper": "Illimité conserve toutes les entrées. Des valeurs plus faibles libèrent de la mémoire mais perdent l'historique.", + "allEventsDisplay": "Affichage Tous les événements", + "allEventsDisplayDesc": "Filtre en lecture seule pour la liste agrégée Tous les événements. Les logs sous-jacents ne sont pas supprimés — repassez sur Illimité pour tout restaurer.", + "allEventsDisplayHelper": "Des valeurs plus faibles conservent les N entrées les plus récentes au prix de l'historique plus ancien.", "bodyView": "Affichage du corps", "tabAnimation": "Animation d'onglet", "tabAnimationDuration": "Durée", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 3164d37..647acc0 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -119,6 +119,13 @@ "tabVisibilityDesc": "タブの表示/非表示を切り替えます。無効なタブはロックアイコンが表示され、すべてのイベントからデータが除外されます。", "detailView": "詳細ビュー", "detailViewDesc": "リクエスト/レスポンスの表示方法を記憶し、タブ切り替えアニメーションを制御します。", + "dataRetention": "データ保持", + "dataRetentionDesc": "各機能リストに適用されるハードキャップ。リストが上限を超えると、最も古いエントリがFIFOで削除されます。保留中の非同期操作は保持されます。", + "maxItems": "最大件数", + "dataRetentionHelper": "無制限はすべてのエントリを保持します。値を小さくするとメモリを解放しますが履歴を失います。", + "allEventsDisplay": "全イベント表示", + "allEventsDisplayDesc": "集約された全イベントリストの表示専用フィルター。元のログは削除されません — 無制限に戻すとすべて復元されます。", + "allEventsDisplayHelper": "値を小さくすると最新のN件のみ表示され、過去の履歴は失われます。", "bodyView": "ボディ表示", "tabAnimation": "タブアニメーション", "tabAnimationDuration": "期間", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d72f82b..c5c2601 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -819,6 +819,48 @@ abstract class S { /// **'Remembers how request/response bodies are shown and controls tab switching animation.'** String get detailViewDesc; + /// No description provided for @dataRetention. + /// + /// In en, this message translates to: + /// **'Data Retention'** + String get dataRetention; + + /// No description provided for @dataRetentionDesc. + /// + /// In en, this message translates to: + /// **'Hard cap applied to each per-feature list. When a list exceeds the limit, oldest entries are dropped FIFO. Pending async ops are preserved.'** + String get dataRetentionDesc; + + /// No description provided for @maxItems. + /// + /// In en, this message translates to: + /// **'Max items'** + String get maxItems; + + /// No description provided for @dataRetentionHelper. + /// + /// In en, this message translates to: + /// **'Unlimited keeps every entry. Lower values free memory but lose history.'** + String get dataRetentionHelper; + + /// No description provided for @allEventsDisplay. + /// + /// In en, this message translates to: + /// **'All Events Display'** + String get allEventsDisplay; + + /// No description provided for @allEventsDisplayDesc. + /// + /// In en, this message translates to: + /// **'View-only filter for the aggregated All Events list. The underlying logs are not deleted — flip back to Unlimited to restore.'** + String get allEventsDisplayDesc; + + /// No description provided for @allEventsDisplayHelper. + /// + /// In en, this message translates to: + /// **'Lower values keep the most recent N entries visible at the cost of older history.'** + String get allEventsDisplayHelper; + /// No description provided for @bodyView. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7c189f3..300d961 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -448,6 +448,31 @@ class SEn extends S { String get detailViewDesc => 'Remembers how request/response bodies are shown and controls tab switching animation.'; + @override + String get dataRetention => 'Data Retention'; + + @override + String get dataRetentionDesc => + 'Hard cap applied to each per-feature list. When a list exceeds the limit, oldest entries are dropped FIFO. Pending async ops are preserved.'; + + @override + String get maxItems => 'Max items'; + + @override + String get dataRetentionHelper => + 'Unlimited keeps every entry. Lower values free memory but lose history.'; + + @override + String get allEventsDisplay => 'All Events Display'; + + @override + String get allEventsDisplayDesc => + 'View-only filter for the aggregated All Events list. The underlying logs are not deleted — flip back to Unlimited to restore.'; + + @override + String get allEventsDisplayHelper => + 'Lower values keep the most recent N entries visible at the cost of older history.'; + @override String get bodyView => 'Body view'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index c5df596..adb7ad2 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -448,6 +448,31 @@ class SFr extends S { String get detailViewDesc => 'Mémorise l\'affichage des corps de requête/réponse et contrôle l\'animation de changement d\'onglet.'; + @override + String get dataRetention => 'Rétention des données'; + + @override + String get dataRetentionDesc => + 'Limite stricte appliquée à chaque liste par fonctionnalité. Lorsqu\'une liste dépasse la limite, les entrées les plus anciennes sont supprimées en FIFO. Les opérations asynchrones en attente sont préservées.'; + + @override + String get maxItems => 'Éléments max'; + + @override + String get dataRetentionHelper => + 'Illimité conserve toutes les entrées. Des valeurs plus faibles libèrent de la mémoire mais perdent l\'historique.'; + + @override + String get allEventsDisplay => 'Affichage Tous les événements'; + + @override + String get allEventsDisplayDesc => + 'Filtre en lecture seule pour la liste agrégée Tous les événements. Les logs sous-jacents ne sont pas supprimés — repassez sur Illimité pour tout restaurer.'; + + @override + String get allEventsDisplayHelper => + 'Des valeurs plus faibles conservent les N entrées les plus récentes au prix de l\'historique plus ancien.'; + @override String get bodyView => 'Affichage du corps'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index f691362..c7a66d6 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -446,6 +446,30 @@ class SJa extends S { @override String get detailViewDesc => 'リクエスト/レスポンスの表示方法を記憶し、タブ切り替えアニメーションを制御します。'; + @override + String get dataRetention => 'データ保持'; + + @override + String get dataRetentionDesc => + '各機能リストに適用されるハードキャップ。リストが上限を超えると、最も古いエントリがFIFOで削除されます。保留中の非同期操作は保持されます。'; + + @override + String get maxItems => '最大件数'; + + @override + String get dataRetentionHelper => + '無制限はすべてのエントリを保持します。値を小さくするとメモリを解放しますが履歴を失います。'; + + @override + String get allEventsDisplay => '全イベント表示'; + + @override + String get allEventsDisplayDesc => + '集約された全イベントリストの表示専用フィルター。元のログは削除されません — 無制限に戻すとすべて復元されます。'; + + @override + String get allEventsDisplayHelper => '値を小さくすると最新のN件のみ表示され、過去の履歴は失われます。'; + @override String get bodyView => 'ボディ表示'; diff --git a/lib/l10n/app_localizations_vi.dart b/lib/l10n/app_localizations_vi.dart index 28ce7fc..64bffbb 100644 --- a/lib/l10n/app_localizations_vi.dart +++ b/lib/l10n/app_localizations_vi.dart @@ -448,6 +448,31 @@ class SVi extends S { String get detailViewDesc => 'Ghi nhớ cách hiển thị nội dung yêu cầu/phản hồi và điều khiển hoạt ảnh chuyển tab.'; + @override + String get dataRetention => 'Giữ dữ liệu'; + + @override + String get dataRetentionDesc => + 'Giới hạn cứng áp dụng cho từng danh sách theo tính năng. Khi danh sách vượt giới hạn, các mục cũ nhất sẽ bị xóa theo FIFO. Tác vụ bất đồng bộ đang chờ được giữ lại.'; + + @override + String get maxItems => 'Số mục tối đa'; + + @override + String get dataRetentionHelper => + 'Không giới hạn giữ mọi mục. Giá trị thấp hơn giải phóng bộ nhớ nhưng mất lịch sử.'; + + @override + String get allEventsDisplay => 'Hiển thị Tất cả sự kiện'; + + @override + String get allEventsDisplayDesc => + 'Bộ lọc chỉ hiển thị cho danh sách Tất cả sự kiện tổng hợp. Log gốc không bị xóa — chuyển về Không giới hạn để khôi phục.'; + + @override + String get allEventsDisplayHelper => + 'Giá trị thấp hơn chỉ giữ N mục mới nhất, đánh đổi bằng lịch sử cũ hơn.'; + @override String get bodyView => 'Hiển thị nội dung'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 4529a6c..d2e3e8e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -441,6 +441,28 @@ class SZh extends S { @override String get detailViewDesc => '记住请求/响应内容的显示方式并控制标签页切换动画。'; + @override + String get dataRetention => '数据保留'; + + @override + String get dataRetentionDesc => + '应用于每个功能列表的硬上限。当列表超过上限时,最早的条目将按 FIFO 顺序删除。待处理的异步操作将被保留。'; + + @override + String get maxItems => '最大条目数'; + + @override + String get dataRetentionHelper => '无限制保留所有条目。较低的值可释放内存,但会丢失历史记录。'; + + @override + String get allEventsDisplay => '所有事件显示'; + + @override + String get allEventsDisplayDesc => '聚合的所有事件列表的仅查看过滤器。底层日志不会被删除 — 切换回无限制即可恢复。'; + + @override + String get allEventsDisplayHelper => '较低的值仅保留最近的 N 条记录,代价是较早的历史记录。'; + @override String get bodyView => '内容视图'; @@ -1518,6 +1540,28 @@ class SZhCn extends SZh { @override String get detailViewDesc => '记住请求/响应内容的显示方式并控制标签页切换动画。'; + @override + String get dataRetention => '数据保留'; + + @override + String get dataRetentionDesc => + '应用于每个功能列表的硬上限。当列表超过上限时,最早的条目将按 FIFO 顺序删除。待处理的异步操作将被保留。'; + + @override + String get maxItems => '最大条目数'; + + @override + String get dataRetentionHelper => '无限制保留所有条目。较低的值可释放内存,但会丢失历史记录。'; + + @override + String get allEventsDisplay => '所有事件显示'; + + @override + String get allEventsDisplayDesc => '聚合的所有事件列表的仅查看过滤器。底层日志不会被删除 — 切换回无限制即可恢复。'; + + @override + String get allEventsDisplayHelper => '较低的值仅保留最近的 N 条记录,代价是较早的历史记录。'; + @override String get bodyView => '内容视图'; @@ -1530,6 +1574,12 @@ class SZhCn extends SZh { @override String get codeModeDesc => '代码模式根据已连接的 SDK 导出为 TypeScript / Dart / Kotlin。'; + @override + String get treeModeDesc => '树形模式以可展开/折叠的节点层级显示数据,适合浏览深度嵌套的值。'; + + @override + String get jsonModeDesc => 'JSON 模式将数据呈现为单一、带语法高亮、易于复制的 JSON 文档。'; + @override String get captureDataJson => '截图数据(当前模式下的键 + 值)'; @@ -2589,6 +2639,28 @@ class SZhTw extends SZh { @override String get detailViewDesc => '記住請求/回應內容的顯示方式並控制分頁切換動畫。'; + @override + String get dataRetention => '資料保留'; + + @override + String get dataRetentionDesc => + '套用於每個功能清單的硬上限。當清單超過上限時,最舊的條目將依 FIFO 順序刪除。等待中的非同步操作將被保留。'; + + @override + String get maxItems => '最大項目數'; + + @override + String get dataRetentionHelper => '無限制保留所有項目。較低的值可釋放記憶體,但會遺失歷史記錄。'; + + @override + String get allEventsDisplay => '所有事件顯示'; + + @override + String get allEventsDisplayDesc => '彙總的所有事件清單的僅檢視篩選器。底層記錄不會被刪除 — 切換回無限制即可還原。'; + + @override + String get allEventsDisplayHelper => '較低的值僅保留最近的 N 筆記錄,代價是較舊的歷史記錄。'; + @override String get bodyView => '內容檢視'; @@ -2601,6 +2673,12 @@ class SZhTw extends SZh { @override String get codeModeDesc => '程式碼模式根據已連線的 SDK 匯出為 TypeScript / Dart / Kotlin。'; + @override + String get treeModeDesc => '樹狀模式以可展開/摺疊的節點階層顯示資料,適合瀏覽深度巢狀的值。'; + + @override + String get jsonModeDesc => 'JSON 模式將資料呈現為單一、帶語法醒目提示、易於複製的 JSON 文件。'; + @override String get captureDataJson => '擷取資料 (目前模式的金鑰 + 值)'; diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index ec9078e..2a48bdc 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -119,6 +119,13 @@ "tabVisibilityDesc": "Bật/tắt tab hiển thị. Tab bị tắt sẽ hiển thị biểu tượng khóa và dữ liệu bị loại trừ khỏi Tất cả sự kiện.", "detailView": "Chi tiết", "detailViewDesc": "Ghi nhớ cách hiển thị nội dung yêu cầu/phản hồi và điều khiển hoạt ảnh chuyển tab.", + "dataRetention": "Giữ dữ liệu", + "dataRetentionDesc": "Giới hạn cứng áp dụng cho từng danh sách theo tính năng. Khi danh sách vượt giới hạn, các mục cũ nhất sẽ bị xóa theo FIFO. Tác vụ bất đồng bộ đang chờ được giữ lại.", + "maxItems": "Số mục tối đa", + "dataRetentionHelper": "Không giới hạn giữ mọi mục. Giá trị thấp hơn giải phóng bộ nhớ nhưng mất lịch sử.", + "allEventsDisplay": "Hiển thị Tất cả sự kiện", + "allEventsDisplayDesc": "Bộ lọc chỉ hiển thị cho danh sách Tất cả sự kiện tổng hợp. Log gốc không bị xóa — chuyển về Không giới hạn để khôi phục.", + "allEventsDisplayHelper": "Giá trị thấp hơn chỉ giữ N mục mới nhất, đánh đổi bằng lịch sử cũ hơn.", "bodyView": "Hiển thị nội dung", "tabAnimation": "Hoạt ảnh tab", "tabAnimationDuration": "Thời lượng", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a3f0286..224dca9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -119,6 +119,13 @@ "tabVisibilityDesc": "切换标签页的可见性。禁用的标签页会显示锁定图标,其数据将从所有事件中排除。", "detailView": "详细视图", "detailViewDesc": "记住请求/响应内容的显示方式并控制标签页切换动画。", + "dataRetention": "数据保留", + "dataRetentionDesc": "应用于每个功能列表的硬上限。当列表超过上限时,最早的条目将按 FIFO 顺序删除。待处理的异步操作将被保留。", + "maxItems": "最大条目数", + "dataRetentionHelper": "无限制保留所有条目。较低的值可释放内存,但会丢失历史记录。", + "allEventsDisplay": "所有事件显示", + "allEventsDisplayDesc": "聚合的所有事件列表的仅查看过滤器。底层日志不会被删除 — 切换回无限制即可恢复。", + "allEventsDisplayHelper": "较低的值仅保留最近的 N 条记录,代价是较早的历史记录。", "bodyView": "内容视图", "tabAnimation": "标签页动画", "tabAnimationDuration": "持续时间", diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index e85f586..d918452 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -119,10 +119,19 @@ "tabVisibilityDesc": "切换标签页的可见性。禁用的标签页会显示锁定图标,其数据将从所有事件中排除。", "detailView": "详细视图", "detailViewDesc": "记住请求/响应内容的显示方式并控制标签页切换动画。", + "dataRetention": "数据保留", + "dataRetentionDesc": "应用于每个功能列表的硬上限。当列表超过上限时,最早的条目将按 FIFO 顺序删除。待处理的异步操作将被保留。", + "maxItems": "最大条目数", + "dataRetentionHelper": "无限制保留所有条目。较低的值可释放内存,但会丢失历史记录。", + "allEventsDisplay": "所有事件显示", + "allEventsDisplayDesc": "聚合的所有事件列表的仅查看过滤器。底层日志不会被删除 — 切换回无限制即可恢复。", + "allEventsDisplayHelper": "较低的值仅保留最近的 N 条记录,代价是较早的历史记录。", "bodyView": "内容视图", "tabAnimation": "标签页动画", "tabAnimationDuration": "持续时间", "codeModeDesc": "代码模式根据已连接的 SDK 导出为 TypeScript / Dart / Kotlin。", + "treeModeDesc": "树形模式以可展开/折叠的节点层级显示数据,适合浏览深度嵌套的值。", + "jsonModeDesc": "JSON 模式将数据呈现为单一、带语法高亮、易于复制的 JSON 文档。", "captureDataJson": "截图数据(当前模式下的键 + 值)", "captureDataText": "截图数据(键 + 值作为文本)", "copyKey": "复制键", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index df9e436..7d08e65 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -119,10 +119,19 @@ "tabVisibilityDesc": "切換分頁的可見性。停用的分頁會顯示鎖定圖示,其資料將從所有事件中排除。", "detailView": "詳細檢視", "detailViewDesc": "記住請求/回應內容的顯示方式並控制分頁切換動畫。", + "dataRetention": "資料保留", + "dataRetentionDesc": "套用於每個功能清單的硬上限。當清單超過上限時,最舊的條目將依 FIFO 順序刪除。等待中的非同步操作將被保留。", + "maxItems": "最大項目數", + "dataRetentionHelper": "無限制保留所有項目。較低的值可釋放記憶體,但會遺失歷史記錄。", + "allEventsDisplay": "所有事件顯示", + "allEventsDisplayDesc": "彙總的所有事件清單的僅檢視篩選器。底層記錄不會被刪除 — 切換回無限制即可還原。", + "allEventsDisplayHelper": "較低的值僅保留最近的 N 筆記錄,代價是較舊的歷史記錄。", "bodyView": "內容檢視", "tabAnimation": "分頁動畫", "tabAnimationDuration": "持續時間", "codeModeDesc": "程式碼模式根據已連線的 SDK 匯出為 TypeScript / Dart / Kotlin。", + "treeModeDesc": "樹狀模式以可展開/摺疊的節點階層顯示資料,適合瀏覽深度巢狀的值。", + "jsonModeDesc": "JSON 模式將資料呈現為單一、帶語法醒目提示、易於複製的 JSON 文件。", "captureDataJson": "擷取資料 (目前模式的金鑰 + 值)", "captureDataText": "擷取資料 (金鑰 + 值作為文字)", "copyKey": "複製金鑰", diff --git a/lib/l10n/untranslated.txt b/lib/l10n/untranslated.txt index 63a8bdf..9e26dfe 100644 --- a/lib/l10n/untranslated.txt +++ b/lib/l10n/untranslated.txt @@ -1,11 +1 @@ -{ - "zh_CN": [ - "treeModeDesc", - "jsonModeDesc" - ], - - "zh_TW": [ - "treeModeDesc", - "jsonModeDesc" - ] -} +{} \ No newline at end of file diff --git a/lib/models/network/network_entry.dart b/lib/models/network/network_entry.dart index ecee59d..6ca1379 100644 --- a/lib/models/network/network_entry.dart +++ b/lib/models/network/network_entry.dart @@ -3,6 +3,21 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'network_entry.freezed.dart'; part 'network_entry.g.dart'; +/// Possible values for [NetworkEntry.via] — which interceptor path on +/// the SDK side reported this entry. The raw string also flows through +/// from the SDK payload, so any unknown value is preserved as +/// [NetworkVia.unknown] on the next rebuild. +class NetworkVia { + const NetworkVia._(); + + static const String fetch = 'fetch'; + static const String xhr = 'xhr'; + static const String unknown = 'unknown'; + + /// True when [via] should render a visible tag in the UI. + static bool isKnown(String via) => via == fetch || via == xhr; +} + @freezed abstract class NetworkEntry with _$NetworkEntry { const factory NetworkEntry({ @@ -23,6 +38,7 @@ abstract class NetworkEntry with _$NetworkEntry { @Default('app') String source, String? serviceName, String? serviceAction, + @Default(NetworkVia.unknown) String via, }) = _NetworkEntry; factory NetworkEntry.fromJson(Map json) => diff --git a/lib/server/ws_message_handler.dart b/lib/server/ws_message_handler.dart index a3b55e7..a9cf52c 100644 --- a/lib/server/ws_message_handler.dart +++ b/lib/server/ws_message_handler.dart @@ -360,6 +360,7 @@ class WsMessageHandler { source: p['source'] as String? ?? 'app', serviceName: detected?.name, serviceAction: detected?.action, + via: p['via'] as String? ?? NetworkVia.unknown, ); _networkController.add(entry); } diff --git a/macos/Podfile b/macos/Podfile index ff5ddb3..351e12a 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -38,5 +38,8 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '12.0' + end end end diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 09ab1ec..68ef028 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -557,7 +557,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -639,7 +639,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -689,7 +689,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/test_manual/test_url.dart b/test_manual/test_url.dart new file mode 100644 index 0000000..c6169ae --- /dev/null +++ b/test_manual/test_url.dart @@ -0,0 +1,11 @@ +import 'package:devconnect_manage_tool/core/utils/network_url_formatter.dart'; + +void main() { + final url = 'https://ennfhoxhnhsdsxnpkwvd.supabase.co/rest/v1/legal_documents?select=id%2Cdocument_key%2Cdocument_type%2Caudience%2Clanguage%2Cversion%2Ceyebrow%2Ctitle%2Csubtitle%2Csummary_heading%2Csummary_body%2Cconfirm_label%2Cconfirm_sublabel%2Ccta_label%2Ceffective_date%2Csections%2Cnotice_text%2Cnotice_link_text%2Cnotice_link_target%2Cis_active&audience=eq.customer&language=eq.vi&is_active=eq.true&order=document_type.asc'; + print('=== formatUrlPretty ==='); + print(formatUrlPretty(url)); + print('=== formatUrlCompact ==='); + print(formatUrlCompact(url)); + print('=== formatUrlOneLine ==='); + print(formatUrlOneLine(url)); +}