claude_session_viewer/lib/providers/session_provider.dart
Mathias Beaulieu-Duncan 659dade82d perf: Phase 1 critical performance fixes + production macOS build
Performance:
- Move JSON parsing to background isolate via Isolate.run() (eliminates 2-4s UI freeze)
- Cache filteredEntries with key-based invalidation (eliminates O(n) recomputation)
- Debounce search queries at 300ms (prevents cascade rebuilds on keystroke)
- Flatten timeline from 2-level turn×response Column to single virtualized ListView.builder
- Add RepaintBoundary per timeline item (isolates repaints during scroll)
- Use context.select for granular rebuilds instead of top-level context.watch
- Lazy ExpandableCard: child not built until first expand (replaces AnimatedCrossFade)
- Use IndexedStack in AppShell (preserves screen state across tab switches)

Fixes:
- Collect parse errors instead of silently swallowing them
- Show parse error count in timeline filter bar
- Fix overflow in tokens screen pie chart legend

Build:
- Configure Developer ID signing with hardened runtime for production distribution
- Enable secure timestamps for notarization
- Update app name to Claude Session Viewer
- Signed, notarized, stapled DMG distribution
2026-04-07 13:32:13 -04:00

204 lines
5.3 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/agent_info.dart';
import '../models/log_entry.dart';
import '../services/jsonl_parser.dart';
class SessionProvider with ChangeNotifier {
final JsonlParser _parser = JsonlParser();
SessionLog? _session;
bool _isLoading = false;
String? _error;
String? _filePath;
List<ParseError> _parseErrors = const [];
// Filter state
String? _selectedAgentId;
Set<String> _visibleTypes = {'user', 'assistant', 'system'};
String _searchQuery = '';
// Cached filtered results
List<LogEntry> _cachedFilteredEntries = const [];
// Cache key — tracks what was used to compute the cache
_FilterKey? _cachedFilterKey;
// Debounce timer for search
Timer? _searchDebounce;
SessionLog? get session => _session;
bool get isLoading => _isLoading;
String? get error => _error;
String? get filePath => _filePath;
String? get selectedAgentId => _selectedAgentId;
Set<String> get visibleTypes => _visibleTypes;
String get searchQuery => _searchQuery;
List<ParseError> get parseErrors => _parseErrors;
AgentInfo? get selectedAgent {
if (_session == null || _selectedAgentId == null) return null;
return _session!.agents.where((a) => a.id == _selectedAgentId).firstOrNull;
}
List<LogEntry> get filteredEntries {
final key = _FilterKey(
selectedAgentId: _selectedAgentId,
visibleTypes: _visibleTypes,
searchQuery: _searchQuery,
hasSession: _session != null,
);
if (_cachedFilterKey == key) return _cachedFilteredEntries;
// Recompute
_cachedFilterKey = key;
_cachedFilteredEntries = _computeFilteredEntries();
return _cachedFilteredEntries;
}
List<LogEntry> _computeFilteredEntries() {
if (_session == null) return [];
List<LogEntry> entries;
if (_selectedAgentId != null && _selectedAgentId != 'main') {
final agent = selectedAgent;
entries = agent?.messages ?? [];
} else {
entries = _session!.mainConversation;
}
return entries.where((e) {
if (!_visibleTypes.contains(e.type)) return false;
if (_searchQuery.isNotEmpty) {
return _entryMatchesSearch(e, _searchQuery.toLowerCase());
}
return true;
}).toList();
}
bool _entryMatchesSearch(LogEntry entry, String query) {
if (entry is UserEntry) {
return entry.promptText.toLowerCase().contains(query);
}
if (entry is AssistantEntry) {
for (final block in entry.textBlocks) {
if (block.text.toLowerCase().contains(query)) return true;
}
for (final block in entry.toolUseBlocks) {
if (block.name.toLowerCase().contains(query)) return true;
if (block.input.toString().toLowerCase().contains(query)) return true;
}
}
return false;
}
Future<void> loadSession(String filePath) async {
_isLoading = true;
_error = null;
_filePath = filePath;
_selectedAgentId = null;
_searchQuery = '';
_parseErrors = const [];
_invalidateCache();
notifyListeners();
try {
final result = await _parser.parseFile(filePath);
_session = result.sessionLog;
_parseErrors = result.errors;
_error = null;
} catch (e) {
_error = e.toString();
_session = null;
}
_isLoading = false;
notifyListeners();
}
void selectAgent(String? agentId) {
_selectedAgentId = agentId;
_invalidateCache();
notifyListeners();
}
void toggleTypeFilter(String type) {
if (_visibleTypes.contains(type)) {
_visibleTypes = Set.from(_visibleTypes)..remove(type);
} else {
_visibleTypes = Set.from(_visibleTypes)..add(type);
}
_invalidateCache();
notifyListeners();
}
void setSearchQuery(String query) {
_searchQuery = query;
// Update cache immediately but debounce notifyListeners
_invalidateCache();
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
notifyListeners();
});
}
void clearSession() {
_session = null;
_filePath = null;
_error = null;
_selectedAgentId = null;
_searchQuery = '';
_parseErrors = const [];
_searchDebounce?.cancel();
_invalidateCache();
notifyListeners();
}
void _invalidateCache() {
_cachedFilterKey = null;
}
@override
void dispose() {
_searchDebounce?.cancel();
super.dispose();
}
}
/// Immutable key to detect whether the filter result has changed.
class _FilterKey {
final String? selectedAgentId;
final Set<String> visibleTypes;
final String searchQuery;
final bool hasSession;
const _FilterKey({
required this.selectedAgentId,
required this.visibleTypes,
required this.searchQuery,
required this.hasSession,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _FilterKey &&
selectedAgentId == other.selectedAgentId &&
_setEquals(visibleTypes, other.visibleTypes) &&
searchQuery == other.searchQuery &&
hasSession == other.hasSession;
@override
int get hashCode =>
Object.hash(selectedAgentId, Object.hashAll(visibleTypes), searchQuery, hasSession);
static bool _setEquals<T>(Set<T> a, Set<T> b) {
if (a.length != b.length) return false;
for (final v in a) {
if (!b.contains(v)) return false;
}
return true;
}
}