use crate::tui::theme::MissionControlTheme;
use ratatui::style::Color;
use ratatui::text::{Line, Text};
use serde_json::Value;
use std::{collections::BTreeMap, path::PathBuf};
const MAX_TREE_ROWS: usize = 8192;
const MAX_TREE_BYTES: usize = 1024 * 1024;
const OMITTED_NOTICE: &str = "Further file paths omitted (display limit).";
#[derive(Debug, Default, Clone)]
pub(crate) struct SessionFiles {
pub(crate) session_id: Option<String>,
pub(crate) cwd: PathBuf,
dirty: bool,
omitted: bool,
tree_omitted: bool,
paths: BTreeMap<String, u8>,
lines: Vec<(String, u8)>,
pub(crate) scroll: u16,
}
impl SessionFiles {
pub(crate) fn record(
&mut self,
name: &str,
success: bool,
metadata: &Value,
changed: &[PathBuf],
) {
self.dirty = false;
match name {
"read" => self.record_read_results(success, metadata),
"write" => {
if success
|| metadata.get("outcome").and_then(Value::as_str)
== Some("committed_but_undurable")
{
self.record_metadata_path(metadata.get("path"), 2);
}
for path in changed {
self.add_path(&path.to_string_lossy(), 2);
}
}
"hash_edit" => {
for path in changed {
self.add_path(&path.to_string_lossy(), 4);
}
self.record_edit_results(metadata);
}
"bash" => {
if let Some(activity) = metadata
.get("inferred_file_activity")
.and_then(Value::as_array)
{
for item in activity.iter().take(32) {
let reasons = match item.get("operation").and_then(Value::as_str) {
Some("read") => 1,
Some("write") => 2,
Some("edit") => 4,
Some("read_write") => 3,
_ => continue,
};
self.record_metadata_path(item.get("path"), reasons << 3);
}
}
}
_ => {}
}
if self.dirty {
self.rebuild_tree();
}
}
fn record_read_results(&mut self, success: bool, metadata: &Value) {
if let Some(results) = metadata.get("results").and_then(Value::as_array) {
for item in results {
if item.get("success").and_then(Value::as_bool) == Some(true) {
self.record_metadata_path(item.get("path"), 1);
}
}
} else if success {
self.record_metadata_path(metadata.get("path"), 1);
}
}
fn record_edit_results(&mut self, metadata: &Value) {
let Some(files) = metadata.get("files").and_then(Value::as_array) else {
return;
};
for file in files {
let applied = matches!(
file.get("status").and_then(Value::as_str),
Some(
"committed"
| "committed_but_undurable"
| "committed_with_error"
| "destination_written_source_retained"
)
);
if applied && file.get("operation").and_then(Value::as_str) != Some("noop") {
self.record_metadata_path(file.get("path"), 4);
self.record_metadata_path(file.get("destination"), 4);
}
}
}
fn record_metadata_path(&mut self, value: Option<&Value>, label: u8) {
if let Some(path) = value.and_then(Value::as_str) {
self.add_path(path, label);
}
}
fn add_path(&mut self, path: &str, label: u8) {
if path.is_empty() || path.contains("://") {
return;
}
if path.len() > 4096 || path.split('/').count() > 128 {
self.omitted = true;
return;
}
let path = crate::path_utils::lexical_normalize(&self.cwd.join(path));
let path = path
.strip_prefix(&self.cwd)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
if self.paths.len() >= 4096 && !self.paths.contains_key(&path) {
self.omitted = true;
return;
}
let labels = self.paths.entry(path).or_default();
self.dirty |= *labels & label == 0;
*labels |= label;
}
fn rebuild_tree(&mut self) {
let mut tree = FileTree::default();
let mut nodes = 0;
let mut bytes = 0;
self.tree_omitted = false;
for (path, labels) in &self.paths {
if !tree.insert(path, *labels, &mut nodes, &mut bytes) {
self.tree_omitted = true;
break;
}
}
self.lines.clear();
let mut bytes = OMITTED_NOTICE.len() + 1;
self.tree_omitted |= !tree.append_lines("", &mut self.lines, &mut bytes);
}
pub(crate) fn visible_text(
&self,
offset: u16,
height: u16,
theme: MissionControlTheme,
) -> Text<'_> {
let count = self.lines.len().max(1) + usize::from(self.has_omissions());
let lines = (usize::from(offset)..count)
.take(usize::from(height))
.map(|index| {
if let Some((line, reasons)) = self.lines.get(index) {
Line::styled(line.as_str(), file_reason_color(theme, *reasons))
} else if index == 0 && self.lines.is_empty() {
Line::raw("No recorded file activity.")
} else {
Line::raw(OMITTED_NOTICE)
}
})
.collect::<Vec<_>>();
Text::from(lines)
}
fn has_omissions(&self) -> bool {
self.omitted || self.tree_omitted
}
pub(crate) fn max_scroll(&self, height: u16) -> u16 {
self.lines
.len()
.max(1)
.saturating_add(usize::from(self.has_omissions()))
.saturating_sub(usize::from(height))
.min(usize::from(u16::MAX)) as u16
}
}
pub(crate) fn file_reason_color(theme: MissionControlTheme, reasons: u8) -> Color {
let reasons = reasons | (reasons >> 3);
if reasons & 4 != 0 {
theme.diff_changed_color()
} else if reasons & 2 != 0 {
theme.diff_inserted_color()
} else if reasons & 1 != 0 {
theme.text_accent()
} else {
theme.text_primary()
}
}
#[derive(Default)]
struct FileTree {
children: BTreeMap<String, FileTree>,
labels: u8,
}
impl FileTree {
fn insert(&mut self, path: &str, labels: u8, nodes: &mut usize, bytes: &mut usize) -> bool {
let mut node = self;
let components = path
.starts_with('/')
.then_some("/")
.into_iter()
.chain(path.split('/').filter(|part| !part.is_empty()));
for component in components {
if !node.children.contains_key(component) {
if *nodes >= MAX_TREE_ROWS - 1 || *bytes + component.len() > MAX_TREE_BYTES {
return false;
}
*nodes += 1;
*bytes += component.len();
}
node = node.children.entry(component.to_string()).or_default();
}
node.labels |= labels;
true
}
fn append_lines(&self, prefix: &str, lines: &mut Vec<(String, u8)>, bytes: &mut usize) -> bool {
for (index, (name, node)) in self.children.iter().enumerate() {
let last = index + 1 == self.children.len();
let branch = if last { "└── " } else { "├── " };
let labels: String = [
(1, " R", " R (inferred)"),
(2, " W", " W (inferred)"),
(4, " E", " E (inferred)"),
]
.into_iter()
.filter_map(|(bit, confirmed, inferred)| {
if node.labels & bit != 0 {
Some(confirmed)
} else if node.labels & (bit << 3) != 0 {
Some(inferred)
} else {
None
}
})
.collect();
let name = crate::tui::activity::sanitize_and_bound_preview(name).replace('\n', " ");
let folder = if (node.children.is_empty() && node.labels != 0) || name == "/" {
""
} else {
"/"
};
let line = format!("{prefix}{branch}{name}{folder}{labels}");
if lines.len() >= MAX_TREE_ROWS - 1 || *bytes + line.len() + 1 > MAX_TREE_BYTES {
return false;
}
*bytes += line.len() + 1;
lines.push((line, node.labels));
if !node.append_lines(
&format!("{prefix}{}", if last { " " } else { "│ " }),
lines,
bytes,
) {
return false;
}
}
true
}
}