implement builtin.files — workspace file tree with live watcher
Flutter sidebar tab that lazy-loads the workspace tree via IPC
files.ls and refreshes subtrees on files.changed events. Click-to-
open routes through a future editor.open command; until Tier 2
registers it, the execute call no-ops gracefully.
Daemon side adds a new files subsystem:
- files.root returns the resolved workspace root (git root if
present, otherwise cwd)
- files.ls lists a directory with ignore filtering applied
- files.watch starts a recursive Directory.watch and fans
FileSystemEvents out as files.changed IPC events
- FilesService owns the watcher + ignore set lifecycle
IgnoreSet + IgnorePattern implement the common gitignore subset:
anchored (/foo), directory-only (foo/), negation (!foo), **
crossing dirs, ** at trailing position. Built-in layer hides clide-
owned dirs (.git, .pql, .clide, .dart_tool, build, node_modules);
.gitignore + .clideignore at the root layer on top per D-004. Full
multi-file ignore_files: layering from .pql/config.yaml is future
work.
11 new ignore-matcher tests + 5 files.* dispatcher tests.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,31 @@
|
||||
import 'package:clide_app/builtin/files/src/file_tree_view.dart';
|
||||
import 'package:clide_app/extension/extension.dart';
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
/// Workspace filesystem panel. Contributes a sidebar tab that renders
|
||||
/// the workspace file tree rooted at the git root, powered by the
|
||||
/// daemon's `files.*` subsystem (ls + watch with ignore-file
|
||||
/// filtering).
|
||||
class FilesExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.files';
|
||||
@override
|
||||
String get title => 'Files';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'files.tree',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Files',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -100,
|
||||
build: (_) => const FileTreeView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/// State model for the file-tree panel.
|
||||
///
|
||||
/// Owns a map of expanded directory → entries (lazy-loaded), the
|
||||
/// workspace root path, and an IPC subscription to `files.changed`
|
||||
/// events. Invalidation on events is coarse today — a change under
|
||||
/// `a/b/` invalidates every currently-expanded directory that could
|
||||
/// have been affected. Refinement (per-dir change tracking) is a
|
||||
/// clear win once the tree gets large.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class FileTreeController extends ChangeNotifier {
|
||||
FileTreeController({required this.ipc, required this.events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final EventBus events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
String? _rootPath;
|
||||
String? get rootPath => _rootPath;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
bool _watchSubscribed = false;
|
||||
|
||||
final Set<String> _expanded = {''}; // '' = workspace root
|
||||
bool isExpanded(String path) => _expanded.contains(path);
|
||||
|
||||
final Map<String, List<FileEntry>> _entries = {};
|
||||
List<FileEntry>? entriesFor(String path) => _entries[path];
|
||||
|
||||
/// Initial boot: resolve the workspace root, load the root dir,
|
||||
/// subscribe to `files.changed` events.
|
||||
Future<void> load() async {
|
||||
final rootResp = await ipc.request('files.root');
|
||||
if (!rootResp.ok) {
|
||||
_error = rootResp.error?.message ?? 'files.root failed';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_rootPath = rootResp.data['path'] as String?;
|
||||
|
||||
final watchResp = await ipc.request('files.watch');
|
||||
_watchSubscribed = watchResp.ok;
|
||||
|
||||
await _loadDir('');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggle(String path) async {
|
||||
if (_expanded.contains(path)) {
|
||||
_expanded.remove(path);
|
||||
notifyListeners();
|
||||
} else {
|
||||
_expanded.add(path);
|
||||
if (!_entries.containsKey(path)) {
|
||||
await _loadDir(path);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh(String path) async {
|
||||
await _loadDir(path);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadDir(String path) async {
|
||||
final r = await ipc.request('files.ls', args: {'path': path});
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message ?? 'files.ls($path) failed';
|
||||
return;
|
||||
}
|
||||
final raw = (r.data['entries'] as List?) ?? const [];
|
||||
_entries[path] = [
|
||||
for (final e in raw.whereType<Map>())
|
||||
FileEntry(
|
||||
name: e['name']! as String,
|
||||
path: e['path']! as String,
|
||||
isDirectory: e['isDirectory']! as bool,
|
||||
isSymlink: (e['isSymlink'] as bool?) ?? false,
|
||||
sizeBytes: (e['sizeBytes'] as num?)?.toInt(),
|
||||
modifiedMs: (e['modifiedMs'] as num?)?.toInt(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _onEvent(DaemonEvent e) {
|
||||
if (e.subsystem != 'files') return;
|
||||
if (e.kind != 'files.changed') return;
|
||||
// Coarse invalidation: reload the parent directory of the change,
|
||||
// plus the root if the change is at top-level. This keeps the
|
||||
// tree accurate without optimistic local mutation.
|
||||
final path = (e.data['path'] as String?) ?? '';
|
||||
final parent = _parentOf(path);
|
||||
if (_entries.containsKey(parent)) {
|
||||
unawaited(refresh(parent));
|
||||
}
|
||||
}
|
||||
|
||||
static String _parentOf(String path) {
|
||||
final slash = path.lastIndexOf('/');
|
||||
return slash < 0 ? '' : path.substring(0, slash);
|
||||
}
|
||||
|
||||
bool get watchSubscribed => _watchSubscribed;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide_app/kernel/kernel.dart';
|
||||
import 'package:clide_app/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
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;
|
||||
|
||||
@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, fontSize: 12),
|
||||
);
|
||||
}
|
||||
final root = c.rootPath;
|
||||
if (root == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true, fontSize: 12),
|
||||
);
|
||||
}
|
||||
final rootName = root.split(Platform.pathSeparator).last;
|
||||
return 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: [
|
||||
_DirRow(
|
||||
name: rootName,
|
||||
path: '',
|
||||
controller: c,
|
||||
depth: 0,
|
||||
),
|
||||
if (c.isExpanded(''))
|
||||
_Children(path: '', controller: c, depth: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 Tier-2 command; the registry's contract takes
|
||||
// a positional argv, so pass the path as argv[0]. Response is
|
||||
// ignored — until the editor extension registers the handler,
|
||||
// execute() returns a not-found error.
|
||||
unawaited(kernel.commands.execute('editor.open', args: [path]));
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatefulWidget {
|
||||
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
|
||||
State<_Row> createState() => _RowState();
|
||||
}
|
||||
|
||||
class _RowState extends State<_Row> {
|
||||
bool _hover = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final leftPadding = 8.0 + (widget.depth * 14.0);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
color: _hover ? tokens.sidebarItemHover : null,
|
||||
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.leading != null) ...[
|
||||
Transform.rotate(
|
||||
angle: widget.rotateLeading ? 1.5708 : 0, // 90° when expanded
|
||||
child: widget.leading,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
] else
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
widget.label,
|
||||
fontSize: 12,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tab.title": { "translation": "Files" },
|
||||
"loading": { "translation": "Loading…" },
|
||||
"empty": { "translation": "No visible files" }
|
||||
}
|
||||
@@ -123,4 +123,5 @@ const List<String> _tier0Namespaces = [
|
||||
'builtin.ipc-status',
|
||||
'builtin.theme-picker',
|
||||
'builtin.terminal',
|
||||
'builtin.files',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user