//! Work panel (Tab): a real file browser over the "document server" —
//! plan, project repo, deliverables, mail, standups, meeting notes — with a
//! live preview of whatever file the cursor is on.
use bevy::prelude::*;
use crate::company::{CompanyCfg, Workspace, WriteKind};
use crate::people::{Names, Person};
use crate::sim::SimClock;
use crate::tasks::{TaskBoard, TaskStatus};
use crate::UIFont;
#[derive(Component)]
pub struct WorkPanel;
#[derive(Clone, Copy, PartialEq, Debug)]
enum Folder {
Plan,
Project,
Deliverables,
Mail,
Standups,
Meetings,
Computer,
GitLog,
}
impl Folder {
const ALL: [Folder; 8] = [
Folder::Plan,
Folder::Project,
Folder::Deliverables,
Folder::Mail,
Folder::Standups,
Folder::Meetings,
Folder::Computer,
Folder::GitLog,
];
fn key(&self) -> KeyCode {
match self {
Folder::Plan => KeyCode::Digit1,
Folder::Project => KeyCode::Digit2,
Folder::Deliverables => KeyCode::Digit3,
Folder::Mail => KeyCode::Digit4,
Folder::Standups => KeyCode::Digit5,
Folder::Meetings => KeyCode::Digit6,
Folder::Computer => KeyCode::Digit7,
Folder::GitLog => KeyCode::Digit8,
}
}
fn digit(&self) -> &'static str {
match self {
Folder::Plan => "1",
Folder::Project => "2",
Folder::Deliverables => "3",
Folder::Mail => "4",
Folder::Standups => "5",
Folder::Meetings => "6",
Folder::Computer => "7",
Folder::GitLog => "8",
}
}
fn name(&self) -> &'static str {
match self {
Folder::Plan => "PLAN",
Folder::Project => "PROJECT",
Folder::Deliverables => "DELIVERABLES",
Folder::Mail => "MAIL",
Folder::Standups => "STANDUPS",
Folder::Meetings => "MEETINGS",
Folder::Computer => "MY COMPUTER",
Folder::GitLog => "GIT LOG",
}
}
fn label(&self, person_slug: &str) -> String {
match self {
Folder::Mail => format!("{}/", person_slug),
Folder::Deliverables => format!("{}/", person_slug),
Folder::Computer => format!("{person_slug}'s computer",),
other => format!("{:?}", other).to_uppercase(),
}
}
}
#[derive(Resource)]
pub struct PanelState {
pub open: bool,
folder: Folder,
cursor: usize,
num_keys: [bool; 8],
}
impl Default for PanelState {
fn default() -> Self {
Self {
open: true, // visible from frame 1; Tab hides it
folder: Folder::Plan,
cursor: 0,
num_keys: [false; 8],
}
}
}
/// One row entry: (display, full file path or None for the plan).
fn rows_for(state: &PanelState, ws: &Workspace, person: &str) -> Vec<(String, Option<std::path::PathBuf>)> {
let mut rows = Vec::new();
match state.folder {
Folder::Plan => rows.push(("plan.md".into(), Some(ws.root.join("plan.md")))),
Folder::Project => {
for (path, _) in ws.project_files() {
let full = ws.project_root().join(&path);
rows.push((path, Some(full)));
}
}
Folder::Deliverables => {
for (file, _) in ws.deliverable_files(person) {
rows.push((file.clone(), Some(ws.deliverable_dir(person).join(file))));
}
}
Folder::Mail => {
for (file, _, unread) in ws.mail_files(person) {
let mark = if unread { "● " } else { " " };
rows.push((format!("{}{}", mark, file), Some(ws.mail_inbox(person).join(file))));
}
}
Folder::Standups => {
for (file, _) in crate::company::list_files(&ws.root.join("standups")) {
let full = ws.root.join("standups").join(&file);
rows.push((file, Some(full)));
}
}
Folder::Meetings => {
for (file, _) in crate::company::list_files(&ws.root.join("meetings")) {
let full = ws.root.join("meetings").join(&file);
rows.push((file, Some(full)));
}
}
Folder::Computer => {
let dir = ws.computer_dir(person);
for (file, _) in crate::company::list_files(&dir) {
let path = file.clone();
rows.push((path, Some(dir.join(&file))));
}
}
Folder::GitLog => {
if let Some(lines) = crate::gitrepo::log(&ws.project_root(), 40) {
for line in lines {
rows.push((line, None));
}
}
}
}
rows
}
pub fn panel(
mut commands: Commands,
font: Res<crate::UIFont>,
keys: Res<ButtonInput<KeyCode>>,
mut state: ResMut<PanelState>,
clock: Res<SimClock>,
cfg: Res<CompanyCfg>,
board: Res<TaskBoard>,
workspace: Res<Workspace>,
selected: Res<crate::people::Selected>,
names: Res<Names>,
people: Query<(&Person, &crate::people::Memory)>,
mut texts: Query<&mut Text, With<WorkPanel>>,
) {
let open = state.open;
if keys.just_pressed(KeyCode::Tab) {
state.open = !state.open;
info!("[panel] {}", if state.open { "open" } else { "closed" });
state.cursor = 0;
}
if state.open {
// folder hotkeys + navigation
for (i, folder) in Folder::ALL.iter().enumerate() {
if keys.just_pressed(folder.key()) {
state.folder = *folder;
state.cursor = 0;
}
let _ = i;
}
let rows_before = rows_for(&state, &workspace, ¤t_person(&cfg, &selected, &names));
let n = rows_before.len();
if n > 0 {
if keys.just_pressed(KeyCode::ArrowUp) {
state.cursor = (state.cursor + n - 1).min(n - 1).max(0);
}
if keys.just_pressed(KeyCode::ArrowDown) {
state.cursor = (state.cursor + 1).min(n - 1);
}
}
}
let mut text = String::new();
if open {
let person = current_person(&cfg, &selected, &names);
let rows = rows_for(&state, &workspace, &person);
text.push_str("── WORK BROWSER [Tab close] [1-6 folder] [↑↓ browse] ──\n");
text.push_str("folders:");
for f in &Folder::ALL {
let tag = if *f == state.folder { "▸" } else { " " };
text.push_str(&format!("{}{}", tag, f.digit()));
}
text.push_str(&format!(
" | {} — Day {} {} — {}/{} done\n",
cfg.company.name,
clock.day(),
clock.hhmm(),
board.tasks.iter().filter(|t| t.status == TaskStatus::Done).count(),
board.tasks.len()
));
text.push_str(&format!("\n▸ FOLDER: {} ({})\n", state.folder.name(), person));
if rows.is_empty() {
text.push_str(" (empty — nobody has written here yet)\n");
}
for (i, (display, _)) in rows.iter().enumerate() {
let cursor = if i == state.cursor.min(rows.len().saturating_sub(1)) {
">"
} else {
" "
};
text.push_str(&format!("{cursor} {display}\n"));
}
// Preview of the selected file.
if let Some(sel) = rows.get(state.cursor.min(rows.len().saturating_sub(1))) {
if let Some(path) = &sel.1 {
let content = std::fs::read_to_string(path)
.unwrap_or_else(|e| format!("(unreadable: {e})"));
text.push_str(&format!("\n── {} ──\n", sel.0));
text.push_str(&preview(&content, 9000));
}
} else {
text.push_str("\n(no selection)\n");
}
// Board snapshot always visible below
text.push_str("\n── TASK BOARD ──\n");
for t in &board.tasks {
let who = t.assignee.as_deref().unwrap_or("unassigned");
let flag = match t.status {
TaskStatus::Done => "✔",
TaskStatus::InReview => "⇄",
TaskStatus::ChangesRequested => "↩",
TaskStatus::InProgress => "…",
TaskStatus::Assigned => "•",
TaskStatus::Backlog => "·",
};
text.push_str(&format!(
"{flag}[{}] {} {}% → {}{}\n",
t.id,
t.status.label(),
t.progress,
who,
t.due_day.map(|d| format!(" d{d}")).unwrap_or_default()
));
if let Some(c) = &t.concern {
text.push_str(&format!(" ⚠ {}\n", short(c, 80)));
}
}
}
match texts.single_mut() {
Ok(mut t) => {
*t = if open { Text::new(text) } else { Text::default() };
}
Err(_) => {
commands.spawn((
Text::default(),
TextFont {
font: font.0.clone().into(),
font_size: 12.5.into(),
..default()
},
TextColor(Color::srgba(0.85, 0.92, 1.0, 0.97)),
BackgroundColor(Color::srgba(0.02, 0.03, 0.06, 0.94)),
Node {
position_type: PositionType::Absolute,
right: Val::Px(8.0),
top: Val::Px(8.0),
width: Val::Px(430.0),
height: Val::Px(932.0),
padding: UiRect::all(Val::Px(8.0)),
overflow: Overflow::clip(),
..default()
},
WorkPanel,
));
}
}
}
/// Selected person, or the manager when nobody is clicked.
fn current_person(
cfg: &CompanyCfg,
selected: &crate::people::Selected,
names: &Names,
) -> String {
if let Some(entity) = selected.0 {
if let Some(entry) = names.0.iter().find(|(_, e)| **e == entity) {
return entry.0.clone();
}
}
cfg.people
.iter()
.find(|p| p.is_manager)
.map(|p| crate::company::slug(&p.name))
.unwrap_or_default()
}
fn preview(content: &str, limit: usize) -> String {
let mut out = String::new();
for line in content.lines().take(220) {
out.push_str(line);
out.push('\n');
if out.len() > limit {
out.push_str("… (truncated)\n");
break;
}
}
out
}
fn short(s: &str, n: usize) -> String {
let t = s.trim();
if t.chars().count() <= n {
t.to_string()
} else {
t.chars().take(n).collect::<String>() + "…"
}
}
/// Keep `WriteKind` imported for parity with llm's usage — planner writes go here too.
#[allow(dead_code)]
fn _w(k: WriteKind) -> &'static str {
match k {
WriteKind::Plan => "plan",
WriteKind::Email => "email",
WriteKind::WorkLog => "worklog",
WriteKind::Look => "look",
}
}