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,
pub(crate) focused: bool,
}
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);
}
_ => {}
}
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)
}
#[cfg(test)]
fn text(&self) -> Text<'_> {
self.visible_text(0, u16::MAX, MissionControlTheme::default())
}
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 {
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"), (2, " W"), (4, " E")]
.into_iter()
.filter(|(bit, _)| node.labels & bit != 0)
.map(|(_, label)| label)
.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
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn session_files_clear_immediately_on_new_session() {
let mut state = crate::tui::state::MissionControlState::default();
state.session_files.session_id = Some("old".into());
state.session_files.cwd = PathBuf::from("/old");
state
.session_files
.record("write", true, &json!({"path":"old.rs"}), &[]);
state.session_files.scroll = 12;
state.session_files.focused = true;
state.reset_for_new_session();
assert_eq!(
state.session_files.text().to_string(),
"No recorded file activity."
);
assert!(state.session_files.session_id.is_none());
assert!(state.session_files.cwd.as_os_str().is_empty());
assert_eq!(state.session_files.scroll, 0);
assert!(!state.session_files.focused);
}
#[test]
fn session_files_bound_deep_trees_and_scroll_to_omission_notice() {
let mut files = SessionFiles::default();
let results: Vec<_> = (0..4096).map(|index| {
json!({"path":format!("{index:04}/{}file", "directory/".repeat(125)), "success":true})
}).collect();
files.record("read", true, &json!({"results":results}), &[]);
assert!(files.has_omissions());
assert!(files.lines.len() < MAX_TREE_ROWS);
assert!(files.text().to_string().len() <= MAX_TREE_BYTES);
let offset = files.max_scroll(3);
let visible = files.visible_text(offset, 3, MissionControlTheme::default());
assert_eq!(visible.lines.len(), 3);
assert!(visible.to_string().ends_with(OMITTED_NOTICE));
assert!(
files
.visible_text(offset, 0, MissionControlTheme::default())
.lines
.is_empty()
);
}
#[test]
fn recorded_file_reasons_color_rows_and_keep_combined_labels() {
let mut files = SessionFiles::default();
let theme = MissionControlTheme::default();
files.record("read", true, &json!({"path":"a"}), &[]);
files.record("write", true, &json!({"path":"b"}), &[]);
files.record("read", true, &json!({"path":"c"}), &[]);
files.record("write", true, &json!({"path":"c"}), &[]);
files.record("hash_edit", true, &json!({}), &[PathBuf::from("c")]);
let text = files.visible_text(0, 3, theme);
assert_eq!(text.lines[0].style.fg, Some(theme.text_accent()));
assert_eq!(text.lines[1].style.fg, Some(theme.diff_inserted_color()));
assert_eq!(text.lines[2].style.fg, Some(theme.diff_changed_color()));
assert!(text.lines[2].to_string().ends_with("c R W E"));
}
#[test]
fn session_files_row_limit_keeps_omission_notice_reachable() {
let mut files = SessionFiles::default();
let results: Vec<_> = (0..4096)
.map(|index| json!({"path":format!("{index:04}/dir/file"), "success":true}))
.collect();
files.record("read", true, &json!({"results":results}), &[]);
assert_eq!(files.text().lines.len(), MAX_TREE_ROWS);
assert_eq!(
files
.visible_text(files.max_scroll(1), 1, MissionControlTheme::default())
.to_string(),
OMITTED_NOTICE
);
}
#[test]
fn session_files_survive_history_hydration_and_receive_child_activity() {
use crate::{
output::{ActivityEvent, ActivityId, ActivityStatus, ToolActivityDetail},
providers::ToolCall,
sessions::SessionEvent,
tools::{ToolResult, ToolResultDisplay},
tui::{sessions::commands, state::MissionControlState},
};
let call = ToolCall {
id: "read-1".into(),
name: "read".into(),
arguments: json!({"paths":["src/lib.rs"]}),
};
let result = ToolResult {
tool_name: "read".into(),
success: true,
content: "body".into(),
metadata: json!({"path":"/project/src/lib.rs"}),
display: ToolResultDisplay::default(),
};
let events = vec![
SessionEvent::new(
"tool_call",
"session".into(),
PathBuf::from("/project"),
json!({"call":call}),
),
SessionEvent::new(
"tool_result",
"session".into(),
PathBuf::from("/project"),
json!({"call":call,"result":result}),
),
];
let snapshot = commands::hydrate_session_history_snapshot(&events, &Default::default());
let mut state = MissionControlState::default();
commands::apply_session_hydration_snapshot(&mut state, snapshot);
assert!(state.session_files.text().to_string().contains("lib.rs R"));
state.apply_activity_event(ActivityEvent::ToolResultDetail {
id: ActivityId::new("child/write"),
detail: ToolActivityDetail {
tool_name: "write".into(),
label: "write".into(),
params: Value::Null,
metadata: json!({"path":"/project/src/lib.rs"}),
status: ActivityStatus::Success,
output: "written".into(),
applied_diff: None,
},
});
assert_eq!(
state.session_files.text().to_string(),
"└── src/\n └── lib.rs R W"
);
let empty = commands::hydrate_session_history_snapshot(&[], &Default::default());
commands::apply_session_hydration_snapshot(&mut state, empty);
assert_eq!(
state.session_files.text().to_string(),
"No recorded file activity."
);
}
#[test]
fn session_files_accumulate_sorted_unique_paths_and_partial_success() {
let mut files = SessionFiles {
cwd: PathBuf::from("/project"),
..Default::default()
};
files.record(
"read",
false,
&json!({"results": [
{"path":"/project/src/z.rs", "success":true},
{"path":"/project/src/missing.rs", "success":false},
{"path":"https://example.com", "success":true},
{"path":"/project/src/a.rs", "success":true}
]}),
&[],
);
files.record(
"write",
true,
&json!({"path":"src/a.rs"}),
&[PathBuf::from("/project/src/a.rs")],
);
files.record(
"hash_edit",
false,
&json!({"files":[
{"path":"src/a.rs", "operation":"update", "status":"committed"},
{"path":"src/missing.rs", "operation":"update", "status":"not_written"}
]}),
&[],
);
files.record(
"bash",
true,
&json!({"path":"untracked"}),
&[PathBuf::from("untracked")],
);
assert_eq!(
files.text().to_string(),
"└── src/\n ├── a.rs R W E\n └── z.rs R"
);
}
#[test]
fn session_files_record_move_and_delete_but_not_noop() {
let mut files = SessionFiles::default();
files.record(
"hash_edit",
true,
&json!({"files":[
{"path":"old", "destination":"new", "operation":"move", "status":"committed"},
{"path":"removed", "operation":"delete", "status":"committed"},
{"path":"same", "operation":"noop", "status":"committed"}
]}),
&[],
);
assert_eq!(
files.text().to_string(),
"├── new E\n├── old E\n└── removed E"
);
}
}