Files
clide/lib/builtin/claude/src/file_tail_follower.dart
T
jpmschweitzerandClaude Opus 4.8 c08f249b00 docs: fix 41 unresolved dartdoc references
Convert non-resolving [refs] in doc comments to backtick code-spans across 24
lib/ files (param/field names out of doc scope, method refs on other classes,
non-API strings like regex char-classes and command ids). Verified 0
"unresolved doc reference" warnings via `dart doc --validate-links`. The
dart-doc CI gate (test.yml) never ran before — Gitea Actions was inactive — so
this debt had accumulated unchecked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:09:55 +02:00

81 lines
2.6 KiB
Dart

/// Read-only file follower for the Bash live-tail sub-card (T-325).
///
/// clide can't see a running Bash command's stdout (Claude Code owns the
/// process), so to "watch the same output" we open our OWN read-only follower
/// on the file the command tails. This never spawns a process and never
/// touches Claude's command — it just reads the file as it grows, like
/// `tail -f`, and hands new bytes to `onData`.
///
/// Pure dart:io/dart:async (no Flutter) so it's unit-testable. Polls rather
/// than using a watcher so it works uniformly across platforms and survives
/// truncation/rotation (size shrinking → re-read from the top).
library;
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
class FileTailFollower {
FileTailFollower(this.path, {required this.onData, this.tailBytes = 16384, this.interval = const Duration(milliseconds: 300)});
/// Absolute path of the file to follow.
final String path;
/// New bytes appended since the last read (or the initial tail window).
final void Function(Uint8List bytes) onData;
/// On first read, start this many bytes from the end (a `tail -c` window)
/// rather than dumping the whole file.
final int tailBytes;
final Duration interval;
int _pos = 0;
bool _primed = false;
bool _stopped = false;
Timer? _timer;
/// Begin following: emit the initial tail window, then poll for growth.
Future<void> start() async {
await pollOnce();
if (_stopped) return;
_timer = Timer.periodic(interval, (_) => pollOnce());
}
/// One read cycle. Public so tests can drive it deterministically without
/// waiting on the timer. Reads any bytes appended since the last position
/// (or, on the first call, the trailing [tailBytes]); resets to the top if
/// the file shrank (truncated/rotated).
Future<void> pollOnce() async {
if (_stopped) return;
final file = File(path);
if (!await file.exists()) return; // not created yet — keep waiting
final length = await file.length();
if (!_primed) {
_pos = length > tailBytes ? length - tailBytes : 0;
_primed = true;
} else if (length < _pos) {
_pos = 0; // truncated / rotated → re-read from the top
}
if (length <= _pos) return;
final raf = await file.open();
try {
await raf.setPosition(_pos);
final bytes = await raf.read(length - _pos);
_pos = length;
if (!_stopped && bytes.isNotEmpty) onData(Uint8List.fromList(bytes));
} finally {
await raf.close();
}
}
/// Stop following and release the timer. Idempotent.
void stop() {
_stopped = true;
_timer?.cancel();
_timer = null;
}
}