add Claude session storage view with user-driven cleanup

The claude.session-storage command opens a modal listing the workspace's
session transcripts with their on-disk sizes (the <id>.jsonl plus the
<id>/ subagents dir) and a total. Each row deletes with a deliberate
two-click confirm; deletion is guarded against unsafe ids and clide never
removes transcripts on its own. SessionSummary gains a sizeBytes field and
session_index gains formatBytes + deleteSession.

T-148.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 17:02:20 +02:00
co-authored by Claude Opus 4.7
parent 849024b7ee
commit 8907abeca7
8 changed files with 377 additions and 1 deletions
+26
View File
@@ -6,6 +6,8 @@ import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_session_host.dart';
import 'package:clide/builtin/claude/src/session_naming.dart';
import 'package:clide/builtin/claude/src/pane_context_status.dart';
import 'package:clide/builtin/claude/src/session_index.dart';
import 'package:clide/builtin/claude/src/session_storage.dart';
import 'package:clide/builtin/claude/src/team_observer.dart';
import 'package:clide/builtin/claude/src/team_panel_host.dart';
import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux;
@@ -60,6 +62,12 @@ class ClaudeExtension extends ClideExtension {
title: 'Claude: kill all tmux sessions for this repo',
run: _killAllSessions,
),
CommandContribution(
id: 'claude.session-storage',
command: 'claude.session-storage',
title: 'Claude: session storage (disk usage + cleanup)',
run: _manageStorage,
),
// In-pane status slot (T-145): the active Claude pane publishes
// its model · permission-mode · context line here.
StatusItemContribution(
@@ -171,6 +179,24 @@ class ClaudeExtension extends ClideExtension {
return IpcResponse.ok(id: '', data: const {'status': 'killed'});
}
/// Open the session-storage manager: per-session transcript sizes, a total,
/// and a user-driven cleanup (T-148). Enumerates the workspace's sessions
/// and shows the modal; deletion happens inside the dialog.
Future<IpcResponse> _manageStorage(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return IpcResponse.ok(id: '', data: const {});
final resp = await ctx.ipc.request('files.root');
final root = resp.ok ? resp.data['path'] as String? : null;
final home = Platform.environment['HOME'];
if (root == null || home == null) return IpcResponse.ok(id: '', data: const {});
final dir = Directory('$home/.claude/projects/${root.replaceAll('/', '-')}');
final sessions = await listSessions(dir);
await ctx.dialog.show<Object>(
(c, dismiss) => SessionStorageDialog(dir: dir, sessions: sessions, onClose: dismiss),
);
return IpcResponse.ok(id: '', data: const {'status': 'shown'});
}
Future<String?> _primarySessionName() async {
final ctx = _ctx;
if (ctx == null) return null;
+41 -1
View File
@@ -18,6 +18,7 @@ class SessionSummary {
required this.modified,
this.firstUser,
this.lastUser,
this.sizeBytes = 0,
});
/// The session id (the `<uuid>` of `<uuid>.jsonl`).
@@ -29,6 +30,10 @@ class SessionSummary {
final String? firstUser;
final String? lastUser;
/// On-disk size of this session: the transcript `<id>.jsonl` plus its
/// `<id>/` subagents directory, in bytes (T-148).
final int sizeBytes;
/// "first … last" — the picker's primary label. Falls back to the id when
/// the session carries no user prompt.
String get label {
@@ -103,17 +108,52 @@ Future<List<SessionSummary>> listSessions(
for (final f in files) {
final stat = await f.stat();
final bookends = await _bookends(f, window);
final id = _sessionId(f.path);
summaries.add(SessionSummary(
id: _sessionId(f.path),
id: id,
modified: stat.modified,
firstUser: bookends.first,
lastUser: bookends.last,
sizeBytes: stat.size + await _dirSize(Directory('${dir.path}/$id')),
));
}
summaries.sort((a, b) => b.modified.compareTo(a.modified));
return summaries.length > max ? summaries.sublist(0, max) : summaries;
}
/// Total bytes of [dir]'s files (recursive), or 0 if it doesn't exist. Used to
/// fold a session's `<id>/subagents/` transcripts into its reported size.
Future<int> _dirSize(Directory dir) async {
if (!await dir.exists()) return 0;
var total = 0;
await for (final e in dir.list(recursive: true, followLinks: false)) {
if (e is File) total += await e.length();
}
return total;
}
/// Delete a session's transcript (`<id>.jsonl`) and its `<id>/` subagents
/// directory from [dir] (T-148). User-driven only — clide never calls this
/// on its own. [id] must be a bare filename component; a value with a path
/// separator or `..` is rejected to keep the delete inside [dir].
Future<void> deleteSession(Directory dir, String id) async {
if (id.isEmpty || id.contains('/') || id.contains(r'\') || id.contains('..')) {
throw ArgumentError('refusing to delete unsafe session id: $id');
}
final file = File('${dir.path}/$id.jsonl');
if (await file.exists()) await file.delete();
final sub = Directory('${dir.path}/$id');
if (await sub.exists()) await sub.delete(recursive: true);
}
/// Human-readable byte size: `0 B`, `12 KB`, `3.4 MB`, `1.2 GB`.
String formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).round()} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
String _sessionId(String path) {
final base = path.split(Platform.pathSeparator).last;
return base.endsWith('.jsonl') ? base.substring(0, base.length - 6) : base;
+164
View File
@@ -0,0 +1,164 @@
/// Modal showing the workspace's Claude session transcripts with their
/// on-disk sizes and a user-driven cleanup (T-148). Per-row delete is a
/// deliberate two-click confirm; clide never deletes transcripts on its own.
/// No-Material (D-7); shown via the [DialogRouter].
library;
import 'dart:io';
import 'package:clide/builtin/claude/src/session_index.dart';
import 'package:clide/builtin/claude/src/session_picker.dart' show relativeTime;
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
typedef SessionDeleter = Future<void> Function(Directory dir, String id);
class SessionStorageDialog extends StatefulWidget {
const SessionStorageDialog({
super.key,
required this.dir,
required this.sessions,
required this.onClose,
this.deleter = deleteSession,
});
final Directory dir;
final List<SessionSummary> sessions;
final VoidCallback onClose;
/// Injected so tests don't touch the real filesystem.
final SessionDeleter deleter;
@override
State<SessionStorageDialog> createState() => _SessionStorageDialogState();
}
class _SessionStorageDialogState extends State<SessionStorageDialog> {
late final List<SessionSummary> _sessions = List.of(widget.sessions);
String? _confirmingId;
int get _total => _sessions.fold(0, (a, s) => a + s.sizeBytes);
Future<void> _delete(SessionSummary s) async {
await widget.deleter(widget.dir, s.id);
if (!mounted) return;
setState(() {
_sessions.remove(s);
_confirmingId = null;
});
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is KeyDownEvent && e.logicalKey == LogicalKeyboardKey.escape) {
widget.onClose();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final theme = ClideTheme.of(context).surface;
return Focus(
autofocus: true,
onKeyEvent: _onKey,
child: Container(
width: 580,
constraints: const BoxConstraints(maxHeight: 460),
decoration: BoxDecoration(
color: theme.panelBackground,
border: Border.all(color: theme.globalBorder),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 4),
child: ClideText(
'Session storage · ${formatBytes(_total)} total',
fontSize: clideFontBody,
color: theme.globalForeground,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: ClideText(
'Deleting a session you are currently using will break that pane.',
muted: true,
fontSize: clideFontSmall,
),
),
if (_sessions.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(14, 4, 14, 16),
child: ClideText('No sessions found for this workspace.', muted: true, fontSize: clideFontSmall),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: _sessions.length,
itemBuilder: (ctx, i) => _row(theme, _sessions[i]),
),
),
],
),
),
);
}
Widget _row(SurfaceTokens theme, SessionSummary s) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(s.label, fontSize: clideFontSmall, color: theme.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
ClideText('${relativeTime(s.modified)} · ${formatBytes(s.sizeBytes)}', muted: true, fontSize: clideFontSmall),
],
),
),
const SizedBox(width: 10),
_deleteControl(theme, s),
],
),
);
}
Widget _deleteControl(SurfaceTokens theme, SessionSummary s) {
if (_confirmingId == s.id) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_action('Delete', theme.globalForeground, () => _delete(s)),
const SizedBox(width: 8),
_action('Keep', theme.globalTextMuted, () => setState(() => _confirmingId = null)),
],
);
}
return _action('Delete', theme.globalTextMuted, () => setState(() => _confirmingId = s.id));
}
Widget _action(String label, Color color, VoidCallback onTap) {
return Semantics(
button: true,
label: label,
child: GestureDetector(
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: ClideText(label, fontSize: clideFontSmall, color: color),
),
),
);
}
}