claude_session_viewer/lib/widgets/common/json_tree_view.dart
Mathias Beaulieu-Duncan 364877d376 Initial commit: Claude Code session viewer (Flutter macOS)
A desktop app that parses Claude Code .jsonl session logs and provides
a rich UI for exploring conversations, tool usage, subagents, and token
consumption. Features include project browser with auto-discovery of
~/.claude/projects, conversation timeline with inline subagent expansion,
agents overview, toolbelt chart, and token usage dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:17:23 -04:00

337 lines
8.2 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../theme/app_theme.dart';
class JsonTreeView extends StatelessWidget {
final dynamic data;
final int depth;
final bool initiallyExpanded;
const JsonTreeView({
super.key,
required this.data,
this.depth = 0,
this.initiallyExpanded = false,
});
@override
Widget build(BuildContext context) {
if (data is Map<String, dynamic>) {
return _MapNode(
map: data as Map<String, dynamic>,
depth: depth,
initiallyExpanded: initiallyExpanded || depth == 0,
);
} else if (data is List) {
return _ListNode(
list: data as List,
depth: depth,
initiallyExpanded: initiallyExpanded || depth == 0,
);
} else {
return _ValueNode(value: data, depth: depth);
}
}
}
class _MapNode extends StatefulWidget {
final Map<String, dynamic> map;
final int depth;
final bool initiallyExpanded;
const _MapNode({
required this.map,
required this.depth,
this.initiallyExpanded = false,
});
@override
State<_MapNode> createState() => _MapNodeState();
}
class _MapNodeState extends State<_MapNode> {
late bool _expanded;
@override
void initState() {
super.initState();
_expanded = widget.initiallyExpanded;
}
@override
Widget build(BuildContext context) {
if (widget.map.isEmpty) {
return Text('{}', style: _valueStyle);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_expanded ? Icons.expand_more : Icons.chevron_right,
size: 14,
color: AppColors.textMuted,
),
const SizedBox(width: 4),
Text(
'{${widget.map.length} keys}',
style: _hintStyle,
),
if (widget.depth == 0) ...[
const SizedBox(width: 8),
_CopyButton(data: widget.map),
],
],
),
),
if (_expanded)
Padding(
padding: const EdgeInsets.only(left: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.map.entries.map((e) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${e.key}: ',
style: _keyStyle,
),
Expanded(
child: JsonTreeView(
data: e.value,
depth: widget.depth + 1,
),
),
],
),
);
}).toList(),
),
),
],
);
}
}
class _ListNode extends StatefulWidget {
final List list;
final int depth;
final bool initiallyExpanded;
const _ListNode({
required this.list,
required this.depth,
this.initiallyExpanded = false,
});
@override
State<_ListNode> createState() => _ListNodeState();
}
class _ListNodeState extends State<_ListNode> {
late bool _expanded;
@override
void initState() {
super.initState();
_expanded = widget.initiallyExpanded;
}
@override
Widget build(BuildContext context) {
if (widget.list.isEmpty) {
return Text('[]', style: _valueStyle);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_expanded ? Icons.expand_more : Icons.chevron_right,
size: 14,
color: AppColors.textMuted,
),
const SizedBox(width: 4),
Text(
'[${widget.list.length} items]',
style: _hintStyle,
),
],
),
),
if (_expanded)
Padding(
padding: const EdgeInsets.only(left: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.list.asMap().entries.map((e) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${e.key}: ',
style: _indexStyle,
),
Expanded(
child: JsonTreeView(
data: e.value,
depth: widget.depth + 1,
),
),
],
),
);
}).toList(),
),
),
],
);
}
}
class _ValueNode extends StatelessWidget {
final dynamic value;
final int depth;
const _ValueNode({required this.value, required this.depth});
@override
Widget build(BuildContext context) {
if (value == null) {
return Text('null', style: _nullStyle);
}
if (value is bool) {
return Text('$value', style: _boolStyle);
}
if (value is num) {
return Text('$value', style: _numberStyle);
}
final str = value.toString();
if (str.length > 200) {
return _LongStringNode(text: str);
}
return SelectableText('"$str"', style: _stringStyle);
}
}
class _LongStringNode extends StatefulWidget {
final String text;
const _LongStringNode({required this.text});
@override
State<_LongStringNode> createState() => _LongStringNodeState();
}
class _LongStringNodeState extends State<_LongStringNode> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Text(
_expanded
? '"${widget.text}"'
: '"${widget.text.substring(0, 200)}..." (${widget.text.length} chars)',
style: _stringStyle,
),
),
],
);
}
}
class _CopyButton extends StatelessWidget {
final dynamic data;
const _CopyButton({required this.data});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
final json = const JsonEncoder.withIndent(' ').convert(data);
Clipboard.setData(ClipboardData(text: json));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Copied to clipboard'),
duration: Duration(seconds: 1),
),
);
},
child: const Icon(
Icons.copy,
size: 14,
color: AppColors.textMuted,
),
);
}
}
const _keyStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.user,
);
const _indexStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.textMuted,
);
const _valueStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.textSecondary,
);
const _stringStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: Color(0xFF10B981),
);
const _numberStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: Color(0xFFF59E0B),
);
const _boolStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: Color(0xFFA855F7),
);
const _nullStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.textMuted,
fontStyle: FontStyle.italic,
);
const _hintStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.textMuted,
);