Files
clide/lib/builtin/files/src/file_tree_view.dart
T
jpmschweitzerandClaude Opus 4.6 cf3e7700a3 sweep ClideTappable across 15 files, remove 267 lines
23 StatefulWidget+State hover pairs converted to StatelessWidget
using ClideTappable(builder: (ctx, hovered, _) => ...). Covers
app.dart (5), welcome (2), claude session host (2), column hat (2),
spine (1), pane chrome (1), tab bar (1), decisions (1), tickets (1),
graph (1), pql panel (1), backlinks (1), file tree (2), git panel (1),
git status (1). Only EditorDragHandle and ClidePalette excluded
(drag/keyboard behavior beyond hover+tap).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:28:35 +02:00

298 lines
8.4 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:clide/src/files/listing.dart' show FileEntry;
import 'file_tree_controller.dart';
/// Sidebar panel rendering the workspace file tree.
///
/// Lazy-expands directories via `files.ls`, subscribes to
/// `files.changed` events from the daemon, and refreshes the affected
/// subtrees on change. Click-to-open is plumbed through `kernel.commands`
/// — today the command doesn't exist yet (lands with Tier 2's editor);
/// the view degrades gracefully to a no-op when the command isn't
/// registered.
class FileTreeView extends StatefulWidget {
const FileTreeView({super.key});
@override
State<FileTreeView> createState() => _FileTreeViewState();
}
class _FileTreeViewState extends State<FileTreeView> {
FileTreeController? _controller;
String _filter = '';
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller != null) return;
final kernel = ClideKernel.of(context);
_controller = FileTreeController(ipc: kernel.ipc, events: kernel.events);
unawaited(_controller!.load());
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final c = _controller;
if (c == null) return const SizedBox.shrink();
return ListenableBuilder(
listenable: c,
builder: (context, _) {
if (c.error != null && c.rootPath == null) {
return Padding(
padding: const EdgeInsets.all(12),
child: ClideText(c.error!, muted: true),
);
}
final root = c.rootPath;
if (root == null) {
return const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true),
);
}
final rootName = root.split(Platform.pathSeparator).last;
return Column(
children: [
ClideFilterBox(hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
Expanded(
child: Semantics(
label: 'file tree — $rootName',
container: true,
explicitChildNodes: true,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_filter.isEmpty) ...[
_DirRow(name: rootName, path: '', controller: c, depth: 0),
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1),
] else
..._filteredEntries(c),
],
),
),
),
),
],
);
},
);
}
List<Widget> _filteredEntries(FileTreeController c) {
final lowerFilter = _filter.toLowerCase();
final matches = c.allLoadedEntries().where((e) {
return e.path.toLowerCase().contains(lowerFilter) || e.name.toLowerCase().contains(lowerFilter);
}).toList();
return [
for (final e in matches) _FilteredFileRow(entry: e),
];
}
}
class _Children extends StatelessWidget {
const _Children({
required this.path,
required this.controller,
required this.depth,
});
final String path;
final FileTreeController controller;
final int depth;
@override
Widget build(BuildContext context) {
final entries = controller.entriesFor(path);
if (entries == null) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
for (final e in entries)
if (e.isDirectory)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_DirRow(
name: e.name,
path: e.path,
controller: controller,
depth: depth,
),
if (controller.isExpanded(e.path))
_Children(path: e.path, controller: controller, depth: depth + 1),
],
)
else
_FileRow(
name: e.name,
path: e.path,
depth: depth,
),
],
);
}
}
class _DirRow extends StatelessWidget {
const _DirRow({
required this.name,
required this.path,
required this.controller,
required this.depth,
});
final String name;
final String path;
final FileTreeController controller;
final int depth;
@override
Widget build(BuildContext context) {
final expanded = controller.isExpanded(path);
final tokens = ClideTheme.of(context).surface;
return Semantics(
button: true,
label: '${expanded ? 'Collapse' : 'Expand'} $name',
onTap: () => controller.toggle(path),
child: _Row(
depth: depth,
onTap: () => controller.toggle(path),
leading: ClideIcon(
const ChevronRightIcon(),
size: 10,
color: tokens.sidebarForeground,
),
label: name,
rotateLeading: expanded,
),
);
}
}
class _FileRow extends StatelessWidget {
const _FileRow({
required this.name,
required this.path,
required this.depth,
});
final String name;
final String path;
final int depth;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Open $name',
onTap: () => _openFile(context, path),
child: _Row(
depth: depth,
onTap: () => _openFile(context, path),
label: name,
),
);
}
void _openFile(BuildContext context, String path) {
final kernel = ClideKernel.of(context);
// editor.open is a daemon-side IPC handler (lib/src/daemon/
// editor_commands.dart), not a kernel command. Fire the request
// and let the editor extension's controller pick up the
// editor.active-changed / editor.opened event — no need to await
// or handle the response here.
unawaited(
kernel.ipc.request('editor.open', args: {'path': path}),
);
}
}
class _Row extends StatelessWidget {
const _Row({
required this.depth,
required this.onTap,
required this.label,
this.leading,
this.rotateLeading = false,
});
final int depth;
final VoidCallback onTap;
final String label;
final Widget? leading;
final bool rotateLeading;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final leftPadding = 8.0 + (depth * 14.0);
return ClideTappable(
onTap: onTap,
builder: (context, hovered, _) => Container(
color: hovered ? tokens.sidebarItemHover : null,
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
child: Row(
children: [
if (leading != null) ...[
Transform.rotate(
angle: rotateLeading ? 1.5708 : 0, // 90° when expanded
child: leading,
),
const SizedBox(width: 6),
] else
const SizedBox(width: 16),
Expanded(
child: ClideText(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: tokens.sidebarForeground,
),
),
],
),
),
);
}
}
class _FilteredFileRow extends StatelessWidget {
const _FilteredFileRow({required this.entry});
final FileEntry entry;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return ClideTappable(
onTap: () {
final kernel = ClideKernel.of(context);
unawaited(kernel.ipc.request('editor.open', args: {'path': entry.path}));
},
builder: (context, hovered, _) => Container(
color: hovered ? tokens.sidebarItemHover : null,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
child: ClideText(entry.path, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground),
),
);
}
}