//! 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)> { 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, keys: Res>, mut state: ResMut, clock: Res, cfg: Res, board: Res, workspace: Res, selected: Res, names: Res, people: Query<(&Person, &crate::people::Memory)>, mut texts: Query<&mut Text, With>, ) { 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::() + "…" } } /// 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", } }