- Add info tooltip: 'Press Cmd+Shift+. in dialogs to show hidden folders' - Better path input hint: 'Path to scan (e.g. ~/.claude/projects)' - Add dialog titles for Load and Browse pickers - Signed, notarized, stapled DMG
1095 lines
37 KiB
Dart
1095 lines
37 KiB
Dart
import 'dart:io';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../providers/session_provider.dart';
|
|
import '../../theme/app_theme.dart';
|
|
|
|
// ─── Data models ─────────────────────────────────────────────
|
|
|
|
class _Project {
|
|
final String dirPath;
|
|
final String rawDirName;
|
|
final String displayName;
|
|
final String fullPath; // the actual filesystem path it maps to
|
|
final List<_SessionFile> sessions;
|
|
final DateTime lastModified;
|
|
|
|
_Project({
|
|
required this.dirPath,
|
|
required this.rawDirName,
|
|
required this.displayName,
|
|
required this.fullPath,
|
|
required this.sessions,
|
|
required this.lastModified,
|
|
});
|
|
|
|
int get sessionCount => sessions.length;
|
|
}
|
|
|
|
class _SessionFile {
|
|
final File file;
|
|
final FileStat stat;
|
|
final String sessionId;
|
|
|
|
_SessionFile({
|
|
required this.file,
|
|
required this.stat,
|
|
required this.sessionId,
|
|
});
|
|
}
|
|
|
|
// ─── Color helpers for project icons ─────────────────────────
|
|
|
|
const _projectColors = [
|
|
Color(0xFF3B82F6),
|
|
Color(0xFF10B981),
|
|
Color(0xFFA855F7),
|
|
Color(0xFFF59E0B),
|
|
Color(0xFFEF4444),
|
|
Color(0xFF06B6D4),
|
|
Color(0xFFF97316),
|
|
Color(0xFF8B5CF6),
|
|
Color(0xFFEC4899),
|
|
Color(0xFF14B8A6),
|
|
];
|
|
|
|
Color _colorForProject(String name) {
|
|
final hash = name.codeUnits.fold<int>(0, (prev, c) => prev + c);
|
|
return _projectColors[hash % _projectColors.length];
|
|
}
|
|
|
|
const _projectIcons = [
|
|
Icons.folder_outlined,
|
|
Icons.code,
|
|
Icons.terminal,
|
|
Icons.cloud_outlined,
|
|
Icons.devices_outlined,
|
|
Icons.science_outlined,
|
|
Icons.hub_outlined,
|
|
Icons.inventory_2_outlined,
|
|
Icons.web_outlined,
|
|
Icons.phone_iphone_outlined,
|
|
];
|
|
|
|
IconData _iconForProject(String name) {
|
|
final lower = name.toLowerCase();
|
|
if (lower.contains('flutter') || lower.contains('app')) return Icons.phone_iphone_outlined;
|
|
if (lower.contains('api') || lower.contains('backend')) return Icons.cloud_outlined;
|
|
if (lower.contains('docker') || lower.contains('helm') || lower.contains('kubernetes') || lower.contains('talos')) return Icons.dns_outlined;
|
|
if (lower.contains('web') || lower.contains('frontend')) return Icons.web_outlined;
|
|
if (lower.contains('agent') || lower.contains('claude') || lower.contains('auto')) return Icons.smart_toy_outlined;
|
|
if (lower.contains('dashboard') || lower.contains('cortex')) return Icons.dashboard_outlined;
|
|
if (lower.contains('doc')) return Icons.description_outlined;
|
|
final hash = name.codeUnits.fold<int>(0, (prev, c) => prev + c);
|
|
return _projectIcons[hash % _projectIcons.length];
|
|
}
|
|
|
|
// ─── Main widget ─────────────────────────────────────────────
|
|
|
|
class HomeScreen extends StatefulWidget {
|
|
final VoidCallback onSessionLoaded;
|
|
const HomeScreen({super.key, required this.onSessionLoaded});
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen> {
|
|
List<_Project> _projects = [];
|
|
bool _scanning = true;
|
|
String _searchQuery = '';
|
|
_Project? _selectedProject;
|
|
_Project? _customFolderProject;
|
|
final _pathController = TextEditingController();
|
|
final _pathFocusNode = FocusNode();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final home = _getRealHome() ?? '';
|
|
_pathController.text = '$home/.claude/projects';
|
|
_scanProjects();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pathController.dispose();
|
|
_pathFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Returns the real user home directory, even inside App Sandbox.
|
|
/// In sandbox, HOME points to ~/Library/Containers/<bundleid>/Data.
|
|
String? _getRealHome() {
|
|
final home = Platform.environment['HOME'];
|
|
if (home == null) return null;
|
|
final match = RegExp(r'^(/Users/[^/]+)/Library/Containers/').firstMatch(home);
|
|
if (match != null) return match.group(1);
|
|
return home;
|
|
}
|
|
|
|
/// Converts the encoded dir name to a human-readable project name.
|
|
/// "-Users-mathias-Documents-workspaces-svrnty-talos-rpi5" → "svrnty / talos-rpi5"
|
|
/// "-Users-mathias" → "~ (home)"
|
|
String _parsePrettyName(String dirName) {
|
|
final home = _getRealHome() ?? '';
|
|
final username = home.split('/').last;
|
|
|
|
// Try to reconstruct the actual filesystem path
|
|
final reconstructed = _reconstructPath(dirName, home);
|
|
if (reconstructed != null) {
|
|
final segs = reconstructed.split('/').where((s) => s.isNotEmpty).toList();
|
|
// Remove common uninteresting segments
|
|
final skip = {'Users', username, 'Documents', 'workspaces', 'Workspaces',
|
|
'Applications', 'Contents', 'Resources', 'Volumes'};
|
|
final meaningful = segs.where((s) => !skip.contains(s)).toList();
|
|
if (meaningful.isEmpty) return '~ (home)';
|
|
if (meaningful.length >= 2) {
|
|
return '${meaningful[meaningful.length - 2]} / ${meaningful.last}';
|
|
}
|
|
return meaningful.last;
|
|
}
|
|
|
|
// Fallback: build dynamic prefixes from the detected username
|
|
final prefixes = [
|
|
'-Users-$username-Documents-workspaces-',
|
|
'-Users-$username-Documents-',
|
|
'-Users-$username-Workspaces-',
|
|
'-Users-$username-',
|
|
'-Volumes-Workspaces-',
|
|
'-Volumes-',
|
|
'-Applications-',
|
|
];
|
|
|
|
String path = dirName;
|
|
for (final p in prefixes) {
|
|
if (path.startsWith(p)) {
|
|
path = path.substring(p.length);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (path.isEmpty) return '~ (home)';
|
|
return path.replaceAll('--', ' / ').replaceAll('-', ' ');
|
|
}
|
|
|
|
/// Try to reconstruct the actual filesystem path from the encoded dir name.
|
|
String? _reconstructPath(String dirName, String home) {
|
|
// The encoding is: replace / with - and prepend -
|
|
// So /Users/mathias/foo becomes -Users-mathias-foo
|
|
// Problem: folder names with dashes are ambiguous.
|
|
// Strategy: try splitting greedily from left, checking if dirs exist.
|
|
if (!dirName.startsWith('-')) return null;
|
|
final candidate = dirName.replaceFirst('-', '/');
|
|
|
|
// Try the obvious: replace all - with /
|
|
final simple = candidate.replaceAll('-', '/');
|
|
if (Directory(simple).existsSync()) return simple;
|
|
|
|
// Otherwise, do a greedy left-to-right directory walk
|
|
final parts = dirName.substring(1).split('-');
|
|
String current = '';
|
|
final resolved = <String>[];
|
|
|
|
for (int i = 0; i < parts.length; i++) {
|
|
if (current.isEmpty) {
|
|
current = parts[i];
|
|
} else {
|
|
current = '$current-${parts[i]}';
|
|
}
|
|
|
|
final testPath = '/${resolved.join('/')}/$current';
|
|
if (Directory(testPath).existsSync() || File(testPath).existsSync()) {
|
|
resolved.add(current);
|
|
current = '';
|
|
}
|
|
}
|
|
if (current.isNotEmpty) resolved.add(current);
|
|
|
|
final result = '/${resolved.join('/')}';
|
|
if (Directory(result).existsSync()) return result;
|
|
return result; // return anyway as best guess
|
|
}
|
|
|
|
/// Locates the Claude Code data directory.
|
|
/// Claude Code always stores data at ~/.claude/ regardless of where
|
|
/// the binary is installed (npm global, homebrew, etc).
|
|
Future<Directory?> _findClaudeDataDir() async {
|
|
final home = _getRealHome();
|
|
debugPrint('[Claude] HOME env: ${Platform.environment['HOME']}');
|
|
debugPrint('[Claude] Real home resolved to: $home');
|
|
|
|
if (home == null) {
|
|
debugPrint('[Claude] ERROR: Could not determine home directory');
|
|
return null;
|
|
}
|
|
|
|
final claudeDir = Directory('$home/.claude');
|
|
final exists = await claudeDir.exists();
|
|
debugPrint('[Claude] Checking ${claudeDir.path} -> exists: $exists');
|
|
|
|
if (exists) return claudeDir;
|
|
|
|
return null;
|
|
}
|
|
|
|
Future<void> _scanProjects() async {
|
|
final claudeData = await _findClaudeDataDir();
|
|
if (claudeData == null) {
|
|
debugPrint('[Claude] No Claude Code data directory found');
|
|
setState(() => _scanning = false);
|
|
return;
|
|
}
|
|
|
|
final projectsDir = Directory('${claudeData.path}/projects');
|
|
final projExists = await projectsDir.exists();
|
|
debugPrint('[Claude] Projects dir: ${projectsDir.path} -> exists: $projExists');
|
|
if (!projExists) {
|
|
setState(() => _scanning = false);
|
|
return;
|
|
}
|
|
|
|
final home = _getRealHome() ?? '';
|
|
final projects = <_Project>[];
|
|
|
|
await for (final projectDir in projectsDir.list()) {
|
|
if (projectDir is! Directory) continue;
|
|
final dirName = projectDir.path.split('/').last;
|
|
|
|
final sessions = <_SessionFile>[];
|
|
|
|
// Scan .jsonl files in the project dir
|
|
try {
|
|
await for (final entity in projectDir.list()) {
|
|
if (entity is File && entity.path.endsWith('.jsonl')) {
|
|
try {
|
|
final stat = entity.statSync();
|
|
final fileName = entity.path.split('/').last;
|
|
sessions.add(_SessionFile(
|
|
file: entity,
|
|
stat: stat,
|
|
sessionId: fileName.replaceAll('.jsonl', ''),
|
|
));
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
if (sessions.isEmpty) continue;
|
|
|
|
sessions.sort((a, b) => b.stat.modified.compareTo(a.stat.modified));
|
|
|
|
final fullPath = _reconstructPath(dirName, home) ?? dirName;
|
|
|
|
projects.add(_Project(
|
|
dirPath: projectDir.path,
|
|
rawDirName: dirName,
|
|
displayName: _parsePrettyName(dirName),
|
|
fullPath: fullPath,
|
|
sessions: sessions,
|
|
lastModified: sessions.first.stat.modified,
|
|
));
|
|
}
|
|
|
|
projects.sort((a, b) => b.lastModified.compareTo(a.lastModified));
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_projects = projects;
|
|
_scanning = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Resolve a path string, expanding ~ to the home directory.
|
|
String _resolvePath(String input) {
|
|
final home = _getRealHome() ?? Platform.environment['HOME'] ?? '';
|
|
var path = input.trim();
|
|
if (path.startsWith('~/')) {
|
|
path = '$home${path.substring(1)}';
|
|
} else if (path == '~') {
|
|
path = home;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
Future<void> _scanFromPathInput() async {
|
|
final resolved = _resolvePath(_pathController.text);
|
|
final dir = Directory(resolved);
|
|
if (await dir.exists()) {
|
|
await _scanFolder(resolved);
|
|
} else {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Folder not found: $resolved'),
|
|
backgroundColor: AppColors.error,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _pickFolder() async {
|
|
try {
|
|
final result = await FilePicker.platform.getDirectoryPath(
|
|
dialogTitle: 'Select a folder containing Claude session files',
|
|
);
|
|
if (result != null) {
|
|
_pathController.text = result;
|
|
await _scanFolder(result);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[Browse] Error: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Could not open folder picker: $e'),
|
|
backgroundColor: AppColors.error,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _scanFolder(String folderPath) async {
|
|
setState(() {
|
|
_scanning = true;
|
|
_selectedProject = null;
|
|
});
|
|
|
|
final sessions = <_SessionFile>[];
|
|
|
|
// Recursively scan for .jsonl files
|
|
final dir = Directory(folderPath);
|
|
if (await dir.exists()) {
|
|
await _scanFolderRecursive(dir, sessions);
|
|
}
|
|
|
|
sessions.sort((a, b) => b.stat.modified.compareTo(a.stat.modified));
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_customFolderProject = _Project(
|
|
dirPath: folderPath,
|
|
rawDirName: folderPath.split('/').last,
|
|
displayName: folderPath.split('/').last,
|
|
fullPath: folderPath,
|
|
sessions: sessions,
|
|
lastModified: sessions.isNotEmpty ? sessions.first.stat.modified : DateTime.now(),
|
|
);
|
|
_selectedProject = _customFolderProject;
|
|
_scanning = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _scanFolderRecursive(Directory dir, List<_SessionFile> sessions) async {
|
|
try {
|
|
await for (final entity in dir.list()) {
|
|
if (entity is File && entity.path.endsWith('.jsonl')) {
|
|
try {
|
|
final stat = entity.statSync();
|
|
final fileName = entity.path.split('/').last;
|
|
sessions.add(_SessionFile(
|
|
file: entity,
|
|
stat: stat,
|
|
sessionId: fileName.replaceAll('.jsonl', ''),
|
|
));
|
|
} catch (_) {}
|
|
} else if (entity is Directory) {
|
|
await _scanFolderRecursive(entity, sessions);
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _loadSingleFile() async {
|
|
try {
|
|
final result = await FilePicker.platform.pickFiles(
|
|
type: FileType.custom,
|
|
allowedExtensions: ['jsonl'],
|
|
dialogTitle: 'Select a Claude session file (.jsonl)',
|
|
);
|
|
if (result != null && result.files.single.path != null) {
|
|
await _loadFile(result.files.single.path!);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[Load] Error: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Could not open file picker: $e'),
|
|
backgroundColor: AppColors.error,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadFile(String path) async {
|
|
final provider = context.read<SessionProvider>();
|
|
await provider.loadSession(path);
|
|
if (provider.error == null && mounted) {
|
|
widget.onSessionLoaded();
|
|
}
|
|
}
|
|
|
|
List<_Project> get _filteredProjects {
|
|
if (_searchQuery.isEmpty) return _projects;
|
|
final q = _searchQuery.toLowerCase();
|
|
return _projects.where((p) =>
|
|
p.displayName.toLowerCase().contains(q) ||
|
|
p.fullPath.toLowerCase().contains(q)).toList();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final provider = context.watch<SessionProvider>();
|
|
|
|
return Scaffold(
|
|
backgroundColor: AppColors.background,
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Header with breadcrumb
|
|
_buildHeader(provider),
|
|
const SizedBox(height: 16),
|
|
_buildSearchBar(),
|
|
const SizedBox(height: 16),
|
|
|
|
if (provider.isLoading)
|
|
const Padding(
|
|
padding: EdgeInsets.only(bottom: 12),
|
|
child: LinearProgressIndicator(
|
|
color: AppColors.assistant,
|
|
backgroundColor: AppColors.surfaceLight,
|
|
),
|
|
),
|
|
|
|
if (provider.error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.errorBg,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: AppColors.error.withAlpha(80)),
|
|
),
|
|
child: Text(
|
|
provider.error!,
|
|
style: const TextStyle(color: AppColors.error, fontSize: 13),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Content
|
|
if (_scanning)
|
|
const Expanded(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(color: AppColors.assistant),
|
|
SizedBox(height: 12),
|
|
Text('Scanning projects...',
|
|
style: TextStyle(color: AppColors.textMuted, fontSize: 13)),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
else if (_selectedProject != null)
|
|
Expanded(child: _buildSessionList(_selectedProject!))
|
|
else
|
|
Expanded(child: _buildProjectGrid()),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── Header with breadcrumb ──────────────────────────────
|
|
|
|
Widget _buildHeader(SessionProvider provider) {
|
|
return Column(
|
|
children: [
|
|
// Row 1: breadcrumb + count
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.terminal, size: 24, color: AppColors.assistant),
|
|
const SizedBox(width: 10),
|
|
InkWell(
|
|
onTap: () => setState(() => _selectedProject = null),
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Text(
|
|
'Projects',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: _selectedProject != null
|
|
? AppColors.assistant
|
|
: AppColors.textPrimary,
|
|
decoration: _selectedProject != null
|
|
? TextDecoration.underline
|
|
: TextDecoration.none,
|
|
decorationColor: AppColors.assistant,
|
|
),
|
|
),
|
|
),
|
|
if (_selectedProject != null) ...[
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
|
child: Icon(Icons.chevron_right, size: 18, color: AppColors.textMuted),
|
|
),
|
|
Flexible(
|
|
child: Text(
|
|
_selectedProject!.displayName,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
const Spacer(),
|
|
Text(
|
|
_selectedProject != null
|
|
? '${_selectedProject!.sessionCount} session${_selectedProject!.sessionCount == 1 ? '' : 's'}'
|
|
: '${_projects.length} projects',
|
|
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
// Row 2: path input + Load + Browse
|
|
Row(
|
|
children: [
|
|
// Path input field — flexible to fill available space
|
|
Expanded(
|
|
child: SizedBox(
|
|
height: 36,
|
|
child: TextField(
|
|
controller: _pathController,
|
|
focusNode: _pathFocusNode,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.textPrimary,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
decoration: InputDecoration(
|
|
hintText: 'Path to scan (e.g. ~/.claude/projects)',
|
|
hintStyle: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.textMuted,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 8,
|
|
),
|
|
filled: true,
|
|
fillColor: AppColors.surface,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(6),
|
|
borderSide: const BorderSide(color: AppColors.surfaceBorder),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(6),
|
|
borderSide: const BorderSide(color: AppColors.surfaceBorder),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(6),
|
|
borderSide: const BorderSide(color: AppColors.assistant),
|
|
),
|
|
suffixIcon: IconButton(
|
|
icon: const Icon(Icons.arrow_forward, size: 14,
|
|
color: AppColors.textMuted),
|
|
tooltip: 'Scan this folder',
|
|
onPressed: _scanFromPathInput,
|
|
),
|
|
),
|
|
onSubmitted: (_) => _scanFromPathInput(),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
SizedBox(
|
|
height: 36,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _loadSingleFile,
|
|
icon: const Icon(Icons.insert_drive_file_outlined, size: 14),
|
|
label: const Text('Load'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.surfaceLight,
|
|
foregroundColor: AppColors.textPrimary,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 0),
|
|
minimumSize: Size.zero,
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(6),
|
|
side: const BorderSide(color: AppColors.surfaceBorder),
|
|
),
|
|
textStyle: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
SizedBox(
|
|
height: 36,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _pickFolder,
|
|
icon: const Icon(Icons.folder_open, size: 14),
|
|
label: const Text('Browse'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.surfaceLight,
|
|
foregroundColor: AppColors.textPrimary,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 0),
|
|
minimumSize: Size.zero,
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(6),
|
|
side: const BorderSide(color: AppColors.surfaceBorder),
|
|
),
|
|
textStyle: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Tooltip(
|
|
message: 'Press Cmd+Shift+. in dialogs to show hidden folders',
|
|
child: Icon(Icons.info_outline, size: 14, color: AppColors.textMuted),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ─── Search bar ──────────────────────────────────────────
|
|
|
|
Widget _buildSearchBar() {
|
|
return TextField(
|
|
onChanged: (v) => setState(() => _searchQuery = v),
|
|
style: const TextStyle(fontSize: 13, color: AppColors.textPrimary),
|
|
decoration: InputDecoration(
|
|
hintText: _selectedProject != null
|
|
? 'Search sessions...'
|
|
: 'Search projects...',
|
|
hintStyle: const TextStyle(fontSize: 13, color: AppColors.textMuted),
|
|
prefixIcon: const Icon(Icons.search, size: 18, color: AppColors.textMuted),
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
|
filled: true,
|
|
fillColor: AppColors.surface,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: AppColors.surfaceBorder),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: AppColors.surfaceBorder),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: AppColors.assistant),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── Project grid ────────────────────────────────────────
|
|
|
|
Widget _buildProjectGrid() {
|
|
final filtered = _filteredProjects;
|
|
if (filtered.isEmpty) {
|
|
return const Center(
|
|
child: Text('No projects found', style: TextStyle(color: AppColors.textMuted)),
|
|
);
|
|
}
|
|
|
|
return LayoutBuilder(builder: (context, constraints) {
|
|
final crossAxisCount = constraints.maxWidth > 900
|
|
? 4
|
|
: constraints.maxWidth > 600
|
|
? 3
|
|
: 2;
|
|
|
|
return GridView.builder(
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: crossAxisCount,
|
|
mainAxisSpacing: 12,
|
|
crossAxisSpacing: 12,
|
|
childAspectRatio: 1.6,
|
|
),
|
|
itemCount: filtered.length,
|
|
itemBuilder: (context, index) => _ProjectCard(
|
|
project: filtered[index],
|
|
onTap: () => setState(() {
|
|
_selectedProject = filtered[index];
|
|
_searchQuery = '';
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
// ─── Session list (inside a project) ─────────────────────
|
|
|
|
Widget _buildSessionList(_Project project) {
|
|
final sessions = _searchQuery.isEmpty
|
|
? project.sessions
|
|
: project.sessions.where((s) =>
|
|
s.sessionId.toLowerCase().contains(_searchQuery.toLowerCase())).toList();
|
|
|
|
if (sessions.isEmpty) {
|
|
return const Center(
|
|
child: Text('No matching sessions', style: TextStyle(color: AppColors.textMuted)),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Project info bar
|
|
Container(
|
|
padding: const EdgeInsets.all(14),
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: AppColors.surfaceBorder),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: _colorForProject(project.displayName).withAlpha(25),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
_iconForProject(project.displayName),
|
|
size: 18,
|
|
color: _colorForProject(project.displayName),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
project.displayName,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
Text(
|
|
project.fullPath,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: AppColors.textMuted,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Session list
|
|
Expanded(
|
|
child: ListView.builder(
|
|
itemCount: sessions.length,
|
|
itemBuilder: (context, index) {
|
|
final session = sessions[index];
|
|
return _SessionTile(
|
|
session: session,
|
|
index: index,
|
|
isActive: context.read<SessionProvider>().filePath == session.file.path,
|
|
onTap: () => _loadFile(session.file.path),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ─── Formatting helpers ──────────────────────────────────
|
|
|
|
static String formatDate(DateTime date) {
|
|
final now = DateTime.now();
|
|
final diff = now.difference(date);
|
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
|
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
|
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
return '${months[date.month - 1]} ${date.day}';
|
|
}
|
|
|
|
static String formatDateTime(DateTime date) {
|
|
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
return '${months[date.month - 1]} ${date.day}, ${date.year} at '
|
|
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
static String formatSize(int bytes) {
|
|
final kb = bytes / 1024;
|
|
final mb = kb / 1024;
|
|
if (mb >= 1) return '${mb.toStringAsFixed(1)} MB';
|
|
return '${kb.toStringAsFixed(0)} KB';
|
|
}
|
|
}
|
|
|
|
// ─── Project card widget ───────────────────────────────────
|
|
|
|
class _ProjectCard extends StatefulWidget {
|
|
final _Project project;
|
|
final VoidCallback onTap;
|
|
|
|
const _ProjectCard({required this.project, required this.onTap});
|
|
|
|
@override
|
|
State<_ProjectCard> createState() => _ProjectCardState();
|
|
}
|
|
|
|
class _ProjectCardState extends State<_ProjectCard> {
|
|
bool _hovered = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = _colorForProject(widget.project.displayName);
|
|
final icon = _iconForProject(widget.project.displayName);
|
|
|
|
return MouseRegion(
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: GestureDetector(
|
|
onTap: widget.onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: _hovered ? AppColors.surfaceLight : AppColors.surface,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: _hovered ? color.withAlpha(60) : AppColors.surfaceBorder,
|
|
width: _hovered ? 1.5 : 1,
|
|
),
|
|
boxShadow: _hovered
|
|
? [BoxShadow(color: color.withAlpha(15), blurRadius: 20, spreadRadius: 2)]
|
|
: null,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: color.withAlpha(25),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(icon, size: 18, color: color),
|
|
),
|
|
const Spacer(),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: color.withAlpha(15),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
'${widget.project.sessionCount}',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: color,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
widget.project.displayName,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_HomeScreenState.formatDate(widget.project.lastModified),
|
|
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Session tile widget ───────────────────────────────────
|
|
|
|
class _SessionTile extends StatefulWidget {
|
|
final _SessionFile session;
|
|
final int index;
|
|
final bool isActive;
|
|
final VoidCallback onTap;
|
|
|
|
const _SessionTile({
|
|
required this.session,
|
|
required this.index,
|
|
required this.isActive,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
State<_SessionTile> createState() => _SessionTileState();
|
|
}
|
|
|
|
class _SessionTileState extends State<_SessionTile> {
|
|
bool _hovered = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final dt = widget.session.stat.modified;
|
|
final isActive = widget.isActive;
|
|
|
|
return MouseRegion(
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: GestureDetector(
|
|
onTap: widget.onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 120),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
margin: const EdgeInsets.only(bottom: 6),
|
|
decoration: BoxDecoration(
|
|
color: isActive
|
|
? AppColors.assistant.withAlpha(12)
|
|
: _hovered
|
|
? AppColors.surfaceLight
|
|
: AppColors.surface,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: isActive
|
|
? AppColors.assistant.withAlpha(50)
|
|
: _hovered
|
|
? AppColors.surfaceBorder.withAlpha(200)
|
|
: AppColors.surfaceBorder,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
// Session number circle
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: isActive
|
|
? AppColors.assistant.withAlpha(30)
|
|
: AppColors.surfaceLight,
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'${widget.index + 1}',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: isActive ? AppColors.assistant : AppColors.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
// Session info
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_HomeScreenState.formatDateTime(dt),
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.w500,
|
|
color: isActive ? AppColors.assistant : AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
widget.session.sessionId,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: AppColors.textMuted,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Size + relative time
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
_HomeScreenState.formatSize(widget.session.stat.size),
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.textSecondary,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
_HomeScreenState.formatDate(dt),
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: AppColors.textMuted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.chevron_right,
|
|
size: 18,
|
|
color: isActive
|
|
? AppColors.assistant
|
|
: _hovered
|
|
? AppColors.textSecondary
|
|
: Colors.transparent,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|