use std::io::Read;
use std::path::{Path, PathBuf};
pub(crate) mod wire;
const AGENTS_DIR: &str = "agents";
const EXCLUDE_AT_ROOT: [&str; 2] = [".git", "messages"];
const MAX_DEPTH: usize = 6;
pub(crate) const MAX_ENTRIES: usize = 500;
pub(crate) const PREVIEW_CAP: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileEntry {
pub rel_path: String,
pub size: u64,
pub is_dir: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum FilesView {
Present {
entries: Vec<FileEntry>,
truncated: bool,
},
#[default]
AbsentWorktree,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Preview {
Text(String),
Binary { size: u64 },
Truncated { text: String, size: u64 },
}
pub fn agent_worktree(workspace: &Path, agent_id: &str) -> PathBuf {
workspace.join(AGENTS_DIR).join(agent_id)
}
pub fn build(workspace: &Path, agent_id: &str) -> FilesView {
let root = agent_worktree(workspace, agent_id);
if !root.is_dir() {
return FilesView::AbsentWorktree;
}
let mut entries = Vec::new();
let mut truncated = false;
walk(&root, "", 1, &mut entries, &mut truncated);
FilesView::Present { entries, truncated }
}
fn walk(
dir: &Path,
prefix: &str,
depth: usize,
entries: &mut Vec<FileEntry>,
truncated: &mut bool,
) {
for (name, path) in sorted_children(dir, prefix.is_empty()) {
if entries.len() >= MAX_ENTRIES {
*truncated = true;
return;
}
let (is_dir, size) = entry_meta(&path);
let rel_path = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
entries.push(FileEntry {
rel_path: rel_path.clone(),
size,
is_dir,
});
if is_dir {
if depth < MAX_DEPTH {
walk(&path, &rel_path, depth + 1, entries, truncated);
} else if has_children(&path) {
*truncated = true;
}
}
}
}
fn entry_meta(path: &Path) -> (bool, u64) {
match path.symlink_metadata() {
Ok(meta) if meta.is_dir() => (true, 0),
Ok(meta) => (false, meta.len()),
Err(_) => (false, 0),
}
}
fn sorted_children(dir: &Path, at_root: bool) -> Vec<(String, PathBuf)> {
let Ok(rd) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut children: Vec<(String, PathBuf)> = rd
.flatten()
.filter_map(|e| Some((e.file_name().to_str()?.to_string(), e.path())))
.filter(|(name, _)| !(at_root && EXCLUDE_AT_ROOT.contains(&name.as_str())))
.collect();
children.sort();
children
}
fn has_children(dir: &Path) -> bool {
std::fs::read_dir(dir).is_ok_and(|mut rd| rd.next().is_some())
}
pub fn preview(file: &Path) -> Preview {
let size = std::fs::symlink_metadata(file).map_or(0, |m| m.len());
let Ok(handle) = std::fs::File::open(file) else {
return Preview::Binary { size };
};
let mut buf = Vec::new();
let _ = handle.take(PREVIEW_CAP as u64 + 1).read_to_end(&mut buf);
classify(&buf, size)
}
pub(crate) fn classify(bytes: &[u8], size: u64) -> Preview {
if bytes.contains(&0) {
return Preview::Binary { size };
}
let window = bytes.get(..PREVIEW_CAP).unwrap_or(bytes);
let text = String::from_utf8_lossy(window).into_owned();
if bytes.len() > PREVIEW_CAP {
Preview::Truncated { text, size }
} else {
Preview::Text(text)
}
}
#[cfg(test)]
mod tests;