1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
//! Findings activity-bar panel — workspace-scoped tester / review
//! report archive (`.mnml/findings/*.md`).
//!
//! Zero-config cross-project: `cd ~/Projects/mixr && mnml .` picks up
//! `~/Projects/mixr/.mnml/findings/` automatically. Testers write into
//! that dir; the panel surfaces the results for review.
//!
//! v1 scope: flat list of finding files sorted by mtime desc + a
//! filter row. Click a row → opens the markdown in an editor pane.
//! v2 will add right-click actions (archive / delete / mark reviewed).
//!
//! Structurally mirrors `notes_panel` — same file-cache lifecycle,
//! same filter idiom, same click routing.
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph},
};
use crate::app::App;
use crate::ui::theme;
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let t = theme::cur();
let bg = t.bg_darker;
frame.render_widget(Block::default().style(Style::default().bg(bg)), area);
if area.height < 2 || area.width < 8 {
return;
}
app.rects.findings_panel_files.clear();
app.rects.findings_panel_filter_input = None;
if !app.findings_panel_scanned_once {
app.findings_panel_refresh();
}
let all_files = app.findings_panel_files_cache.clone();
// No dedicated filter state (v1) — reuse the panel's own filter
// buffer if we add one later. For now, always show everything.
let files: Vec<std::path::PathBuf> = all_files.clone();
// Header — "FINDINGS (N)" — count is cheap + useful.
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
"FINDINGS",
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({})", files.len()),
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::DIM),
),
])),
Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
},
);
let mut y = area.y + 2;
if files.is_empty() {
let empty = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled("No findings yet.", Style::default().fg(t.comment).bg(bg)),
]);
frame.render_widget(
Paragraph::new(empty),
Rect {
x: area.x,
y,
width: area.width,
height: 1,
},
);
y += 1;
let hint = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
"Stored under .mnml/findings/*.md",
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::DIM),
),
]);
frame.render_widget(
Paragraph::new(hint),
Rect {
x: area.x,
y,
width: area.width,
height: 1,
},
);
return;
}
// Rows — icon + name + right-aligned age (mtime).
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
// Compute the findings root once so we can render paths relative
// to it — nested tester-round dirs (e.g. `2026-07-21-.../foo.md`)
// read as `round/foo` instead of losing all context to file_stem.
let root = findings_dir(&app.workspace);
for path in files.iter().take(area.height.saturating_sub(3) as usize) {
if y >= area.y + area.height {
break;
}
// Relative-to-findings-root name so nested rows keep their
// round-dir context. Strip the `.md` extension for compactness.
let rel = path.strip_prefix(&root).unwrap_or(path);
let name = rel.with_extension("").to_string_lossy().into_owned();
let name = if name.is_empty() {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("finding")
.to_string()
} else {
name
};
let icon = if app.config.ui.ascii_icons {
"◧"
} else {
// nf-md-file-search — same glyph as the activity-bar icon.
"\u{F1623}"
};
let age_str: String = std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| {
let secs = now.saturating_sub(d.as_secs() as i64);
crate::ui::git_graph_view::humanize_age(secs)
})
.unwrap_or_default();
let name_width = (area.width as usize)
.saturating_sub(4)
.saturating_sub(age_str.chars().count())
.saturating_sub(1);
let name_clipped: String = name.chars().take(name_width).collect();
let name_padded = format!("{name_clipped:<width$}", width = name_width);
let row_rect = Rect {
x: area.x,
y,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(format!("{icon} "), Style::default().fg(t.cyan).bg(bg)),
Span::styled(name_padded, Style::default().fg(t.fg).bg(bg)),
Span::styled(format!(" {age_str}"), Style::default().fg(t.comment).bg(bg)),
])),
row_rect,
);
app.rects
.findings_panel_files
.push((row_rect, path.clone()));
y += 1;
}
}
/// The findings directory for a workspace. Not created eagerly —
/// only when something writes there (test agent, or a future
/// `+ New finding` action).
pub fn findings_dir(workspace: &std::path::Path) -> std::path::PathBuf {
workspace.join(".mnml").join("findings")
}