swarm / office/src/panel.rs

raw file 11233 bytes
//! 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, &current_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",
    }
}