Vectrum
Sign In
603 lines · 25.1 KB · Rust
//! The Genome map: Empryo's ranked map of the open thread's checkout, read
//! from the `.empryo/genome.db` Empryo keeps there, or for a thread on
//! another device, read there and sent over. A surface of the right panel
//! that the sidebar offers while the thread runs on Empryo. The graph shows
//! the highest ranked files and which import which; the list below ranks
//! them, and a file opens to what it exports.

use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::time::{Duration, SystemTime};

use gpui_kit::component::{Icon, IconName, h_flex, v_flex};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::Workspace;
use super::surfaces::Surface;
use crate::genome::{self, Genome, GenomeFile};
use crate::models::Provider;
use crate::remote::GenomeAnswer;
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};

pub(super) const GENOME_PANEL_WIDTH: f32 = 440.;
/// The graph's size inside the panel.
const MAP_WIDTH: f32 = GENOME_PANEL_WIDTH - 12. - 12. - 16.;
const MAP_HEIGHT: f32 = 300.;
/// How often an open map looks for Empryo's updates.
const WATCH_EVERY: Duration = Duration::from_secs(3);
/// How long another device gets to send its Genome.
const REMOTE_TIMEOUT: Duration = Duration::from_secs(20);
/// How many files the map labels by name.
const LABELED: usize = 6;

/// An import line in the map: from, to, and whether it's lit.
type MapLine = ((f32, f32), (f32, f32), bool);

/// Where the checkout a map shows is.
#[derive(Clone, Debug, PartialEq)]
enum GenomeSource {
    Local(PathBuf),
    /// On another device, by its connection's id: a thread's checkout there,
    /// or the project's for a thread not created yet.
    Remote { env: String, thread: Option<String>, project: PathBuf },
}

/// What the Genome map shows for a checkout.
enum GenomeLoad {
    Loading,
    /// Empryo hasn't mapped this checkout yet.
    Missing,
    Failed(String),
    Ready(Arc<Genome>),
}

pub(super) struct GenomePanel {
    source: GenomeSource,
    load: GenomeLoad,
    /// When the database was last written, in milliseconds since the epoch,
    /// as of the last read.
    read_at: Option<u64>,
    reading: bool,
    /// The file opened in the list, by path, so it stays open across reloads.
    selected: Option<String>,
    hovered: Option<usize>,
    _watch: Task<()>,
}

impl Workspace {
    /// Whether the open thread (or the new thread's draft) runs on Empryo,
    /// here or on another device.
    pub(super) fn genome_available(&self, cx: &App) -> bool {
        let provider = match &self.active {
            Some(session) => session.read(cx).provider,
            None => self.current_settings(cx).provider,
        };
        provider == Provider::Empryo && self.genome_source(cx).is_some()
    }

    /// The checkout the open thread works in, and where it is.
    fn genome_source(&self, cx: &App) -> Option<GenomeSource> {
        match &self.active {
            Some(session) => {
                let session = session.read(cx);
                Some(match &session.remote {
                    Some(link) => GenomeSource::Remote {
                        env: link.env.clone(),
                        thread: link.thread.clone(),
                        project: session.project.clone(),
                    },
                    None => GenomeSource::Local(session.cwd.clone()),
                })
            }
            None => {
                let project = self.active_project()?;
                Some(match &project.environment {
                    Some(env) => GenomeSource::Remote { env: env.clone(), thread: None, project: project.path.clone() },
                    None => GenomeSource::Local(project.path.clone()),
                })
            }
        }
    }

    /// Keeps the map on the open thread's checkout while it shows: a thread
    /// in another checkout loads that one's.
    pub(super) fn sync_genome(&mut self, cx: &mut Context<Self>) {
        if !self.surface_open(Surface::Genome) || self.settings_open {
            return;
        }
        let Some(source) = self.genome_source(cx) else { return };
        if self.genome.as_ref().is_some_and(|panel| panel.source == source) {
            return;
        }
        let watch = cx.spawn(async move |this, cx| {
            loop {
                cx.background_executor().timer(WATCH_EVERY).await;
                let alive = this.update(cx, |this, cx| {
                    let Some(panel) = this.genome.as_ref() else { return };
                    if panel.reading || !this.surface_open(Surface::Genome) {
                        return;
                    }
                    // Another device answers with what changed, if anything.
                    let changed = match &panel.source {
                        GenomeSource::Local(cwd) => genome::written_at(cwd) != panel.read_at,
                        GenomeSource::Remote { .. } => true,
                    };
                    if changed {
                        this.read_genome(cx);
                    }
                });
                if alive.is_err() {
                    return;
                }
            }
        });
        self.genome = Some(GenomePanel {
            source,
            load: GenomeLoad::Loading,
            read_at: None,
            reading: false,
            selected: None,
            hovered: None,
            _watch: watch,
        });
        self.read_genome(cx);
    }

    /// Reads the Genome again, in the background, or asks the device it's on.
    fn read_genome(&mut self, cx: &mut Context<Self>) {
        let Some(panel) = self.genome.as_ref() else { return };
        let source = panel.source.clone();
        let since = panel.read_at;
        match source.clone() {
            GenomeSource::Local(cwd) => {
                let read = cx.background_executor().spawn(async move {
                    let written = genome::written_at(&cwd);
                    match genome::load(&cwd) {
                        Ok(genome) => GenomeAnswer { written, genome: genome.map(Box::new), ..GenomeAnswer::default() },
                        Err(err) => GenomeAnswer { written, error: Some(format!("{err:#}")), ..GenomeAnswer::default() },
                    }
                });
                cx.spawn(async move |this, cx| {
                    let answer = read.await;
                    let _ = this.update(cx, |this, cx| this.show_genome(&source, answer, cx));
                })
                .detach();
            }
            GenomeSource::Remote { env, thread, project } => {
                let Some(client) = self.connected_client(&env) else {
                    // It reads again once the device is back.
                    if let Some(panel) = self.genome.as_mut().filter(|panel| !matches!(panel.load, GenomeLoad::Ready(_))) {
                        panel.load = GenomeLoad::Failed("The device this thread runs on isn't connected.".into());
                        cx.notify();
                    }
                    return;
                };
                let (request, reply) = client.request_genome(thread, project, since);
                let timeout = cx.background_executor().timer(REMOTE_TIMEOUT);
                cx.spawn(async move |this, cx| {
                    let answer = smol::future::or(async { reply.recv().await.ok() }, async {
                        timeout.await;
                        None
                    })
                    .await;
                    let answer = answer.unwrap_or_else(|| {
                        client.forget_genome(request);
                        GenomeAnswer {
                            written: since,
                            error: Some(
                                "The device didn't send its Genome. Its Vectrum Code may be too old to; update it from the sidebar's corner."
                                    .into(),
                            ),
                            ..GenomeAnswer::default()
                        }
                    });
                    let _ = this.update(cx, |this, cx| this.show_genome(&source, answer, cx));
                })
                .detach();
            }
        }
        if let Some(panel) = self.genome.as_mut() {
            panel.reading = true;
        }
        cx.notify();
    }

    /// Shows what a read of `source`'s Genome found, if the map still shows it.
    fn show_genome(&mut self, source: &GenomeSource, answer: GenomeAnswer, cx: &mut Context<Self>) {
        let Some(panel) = self.genome.as_mut().filter(|panel| &panel.source == source) else { return };
        panel.reading = false;
        panel.read_at = answer.written;
        if answer.unchanged {
            return;
        }
        panel.hovered = None;
        panel.load = match (answer.error, answer.genome) {
            (Some(error), _) => GenomeLoad::Failed(error),
            (None, Some(genome)) => GenomeLoad::Ready(Arc::new(*genome)),
            (None, None) => GenomeLoad::Missing,
        };
        cx.notify();
    }

    /// Opens the Genome map in the right panel, or closes it.
    pub(super) fn toggle_genome(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        self.toggle_surface(Surface::Genome, window, cx);
    }

    fn select_genome_file(&mut self, path: String, cx: &mut Context<Self>) {
        if let Some(panel) = self.genome.as_mut() {
            panel.selected = if panel.selected.as_deref() == Some(path.as_str()) { None } else { Some(path) };
            cx.notify();
        }
    }

    pub(super) fn render_genome_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let panel = self.genome.as_ref();
        let reading = panel.is_some_and(|panel| panel.reading);
        let genome = panel.and_then(|panel| match &panel.load {
            GenomeLoad::Ready(genome) => Some(genome.clone()),
            _ => None,
        });

        let header = h_flex()
            .h(px(36.))
            .pl(px(10.))
            .gap(px(10.))
            .child(Icon::new(Lucide::Dna).size(px(16.)).text_color(p.icon))
            .child(div().flex_1().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child("Genome"))
            .when_some(genome.as_ref(), |this, genome| {
                this.child(metal::tag(format!("{} files · {} symbols", genome.total_files, genome.total_symbols), p))
            })
            .child(
                MetalButton::new("reload-genome")
                    .ghost()
                    .mini()
                    .icon(Lucide::RotateCcw)
                    .tooltip(if reading { "Reading the Genome…" } else { "Read the Genome again" })
                    .on_click(cx.listener(|this, _, _, cx| this.read_genome(cx))),
            )
            .child(
                MetalButton::new("close-genome")
                    .ghost()
                    .mini()
                    .icon(IconName::Close)
                    .tooltip("Close the Genome map")
                    .on_click(cx.listener(|this, _, _, cx| {
                        this.set_right_panel(None);
                        cx.notify();
                    })),
            );

        let body: AnyElement = match (panel.map(|panel| &panel.load), &genome) {
            (_, Some(genome)) => self.render_genome(genome.clone(), p, cx).into_any_element(),
            (Some(GenomeLoad::Failed(error)), _) => {
                genome_message("Couldn't read the Genome", error.clone(), p).into_any_element()
            }
            (Some(GenomeLoad::Missing), _) => genome_message(
                "No Genome here yet",
                "Empryo maps a checkout when a thread starts in it. Send this thread a message and the map appears here."
                    .to_string(),
                p,
            )
            .into_any_element(),
            _ => genome_message("Reading the Genome…", String::new(), p).into_any_element(),
        };

        v_flex()
            .w(px(GENOME_PANEL_WIDTH))
            .flex_none()
            .h_full()
            .p(px(12.))
            .pl(px(0.))
            .child(
                v_flex()
                    .size_full()
                    .p(px(6.))
                    .rounded(px(18.))
                    .raised(p)
                    .child(header)
                    .child(div().px(px(8.)).py(px(4.)).child(metal::rule(p)))
                    .child(body),
            )
    }

    fn render_genome(&self, genome: Arc<Genome>, p: &Palette, cx: &mut Context<Self>) -> impl IntoElement {
        let panel = self.genome.as_ref();
        let selected = panel.and_then(|panel| panel.selected.clone());
        let selected_ix = selected.as_ref().and_then(|path| genome.files.iter().position(|file| &file.path == path));
        let hovered = panel.and_then(|panel| panel.hovered);
        let focus = hovered.or(selected_ix);
        let updated = panel
            .and_then(|panel| panel.read_at)
            .map(|millis| crate::clock::when(SystemTime::UNIX_EPOCH + Duration::from_millis(millis)));

        let rows: Vec<AnyElement> = genome
            .files
            .iter()
            .enumerate()
            .map(|(ix, file)| self.render_genome_row(ix, file, selected_ix == Some(ix), p, cx).into_any_element())
            .collect();

        v_flex()
            .flex_1()
            .min_h_0()
            .gap(px(6.))
            .child(div().px(px(2.)).child(self.render_genome_map(genome.clone(), focus, p, cx)))
            .child(
                h_flex()
                    .px(px(10.))
                    .gap(px(8.))
                    .text_size(px(11.5))
                    .text_color(p.ink3)
                    .child(div().flex_1().child(format!(
                        "Top {} of {} files by rank · {} imports",
                        genome.files.len(),
                        genome.total_files,
                        genome.total_edges
                    )))
                    .when_some(updated, |this, updated| this.child(format!("updated {updated}"))),
            )
            .child(div().px(px(8.)).child(metal::rule(p)))
            .child(v_flex().id("genome-files").flex_1().min_h_0().overflow_y_scroll().gap(px(1.)).children(rows))
    }

    /// The import graph of the top files: a dot per file, sized by rank and
    /// colored by folder, with a line for each import. The file under the
    /// pointer, or the one open in the list, lights up with its neighbors.
    fn render_genome_map(&self, genome: Arc<Genome>, focus: Option<usize>, p: &Palette, cx: &mut Context<Self>) -> impl IntoElement {
        let max_rank = genome.files.iter().map(|file| file.rank).fold(0., f64::max).max(f64::EPSILON);
        let radius = |file: &GenomeFile| 4. + 10. * (file.rank / max_rank).sqrt() as f32;
        let inset = 18.;
        let spot = |ix: usize| {
            let (x, y) = genome.layout[ix];
            (inset + x * (MAP_WIDTH - 2. * inset), inset + y * (MAP_HEIGHT - 2. * inset))
        };
        let neighbors: Vec<usize> = focus
            .map(|focus| {
                genome
                    .edges
                    .iter()
                    .filter_map(|edge| match (edge.from == focus, edge.to == focus) {
                        (true, _) => Some(edge.to),
                        (_, true) => Some(edge.from),
                        _ => None,
                    })
                    .collect()
            })
            .unwrap_or_default();

        // The import lines, painted under the dots.
        let lines: Rc<Vec<MapLine>> = Rc::new(
            genome
                .edges
                .iter()
                .map(|edge| (spot(edge.from), spot(edge.to), focus.is_some_and(|focus| edge.from == focus || edge.to == focus)))
                .collect(),
        );
        let (dim, lit) = (p.ink3.opacity(0.28), p.tints[2].opacity(0.9));
        let edges = canvas(
            |_, _, _| {},
            move |bounds, _, window, _| {
                for ((x1, y1), (x2, y2), on) in lines.iter() {
                    let mut path = PathBuilder::stroke(px(if *on { 1.6 } else { 1. }));
                    let from = bounds.origin + point(px(*x1), px(*y1));
                    let to = bounds.origin + point(px(*x2), px(*y2));
                    // A slight bend tells crossing lines apart.
                    let mid = point((from.x + to.x) / 2. + (to.y - from.y) * 0.12, (from.y + to.y) / 2. - (to.x - from.x) * 0.12);
                    path.move_to(from);
                    path.curve_to(to, mid);
                    if let Ok(path) = path.build() {
                        window.paint_path(path, if *on { lit } else { dim });
                    }
                }
            },
        )
        .absolute()
        .size_full();

        let dots = genome.files.iter().enumerate().map(|(ix, file)| {
            let (x, y) = spot(ix);
            let r = radius(file);
            let color = folder_color(&file.path, p);
            let on = focus == Some(ix);
            let near = neighbors.contains(&ix);
            let faded = focus.is_some() && !on && !near;
            let path = file.path.clone();
            div()
                .id(SharedString::from(format!("genome-dot-{ix}")))
                .absolute()
                .left(px(x - r))
                .top(px(y - r))
                .size(px(r * 2.))
                .rounded_full()
                .bg(if faded { color.opacity(0.3) } else { color })
                .when(on, |this| this.border_2().border_color(p.ink))
                .when(near && !on, |this| this.border_1().border_color(p.tints[2]))
                .cursor_pointer()
                .on_hover(cx.listener(move |this, hovered: &bool, _, cx| {
                    if let Some(panel) = this.genome.as_mut() {
                        if *hovered {
                            panel.hovered = Some(ix);
                        } else if panel.hovered == Some(ix) {
                            panel.hovered = None;
                        }
                        cx.notify();
                    }
                }))
                .on_click(cx.listener(move |this, _, _, cx| this.select_genome_file(path.clone(), cx)))
        });

        // Names for the top files, and for the one in focus.
        let labeled: Vec<usize> = (0..genome.files.len().min(LABELED)).chain(focus).collect();
        let labels = labeled.into_iter().enumerate().filter(|(n, ix)| *n < LABELED || !(0..LABELED).contains(ix)).map(|(_, ix)| {
            let file = &genome.files[ix];
            let (x, y) = spot(ix);
            let r = radius(file);
            let name = file_name(&file.path);
            let width = (name.chars().count() as f32 * 6.2 + 10.).min(160.);
            // Labels stay inside the map.
            let left = (x - width / 2.).clamp(2., MAP_WIDTH - width - 2.);
            let below = y + r + 18. < MAP_HEIGHT;
            div()
                .absolute()
                .left(px(left))
                .top(px(if below { y + r + 2. } else { y - r - 17. }))
                .w(px(width))
                .flex()
                .justify_center()
                .child(
                    div()
                        .px(px(4.))
                        .rounded(px(4.))
                        .bg(p.page.opacity(0.75))
                        .text_size(px(10.5))
                        .font_family(metal::MONO)
                        .text_color(if focus == Some(ix) { p.ink } else { p.ink2 })
                        .truncate()
                        .child(name),
                )
        });

        div()
            .relative()
            .w(px(MAP_WIDTH))
            .h(px(MAP_HEIGHT))
            .mx(px(6.))
            .rounded(px(12.))
            .well(p)
            .overflow_hidden()
            .child(edges)
            .children(dots)
            .children(labels)
    }

    fn render_genome_row(&self, ix: usize, file: &GenomeFile, open: bool, p: &Palette, cx: &mut Context<Self>) -> impl IntoElement {
        let path = file.path.clone();
        let (dir, name) = match file.path.rsplit_once('/') {
            Some((dir, name)) => (format!("{dir}/"), name.to_string()),
            None => (String::new(), file.path.clone()),
        };
        let row = h_flex()
            .id(SharedString::from(format!("genome-file-{ix}")))
            .h(px(28.))
            .px(px(10.))
            .gap(px(8.))
            .rounded(px(8.))
            .cursor_pointer()
            .hover(|style| style.bg(p.row_hover))
            .when(open, |this| this.bg(p.row_hover))
            .on_hover(cx.listener(move |this, hovered: &bool, _, cx| {
                if let Some(panel) = this.genome.as_mut() {
                    if *hovered {
                        panel.hovered = Some(ix);
                    } else if panel.hovered == Some(ix) {
                        panel.hovered = None;
                    }
                    cx.notify();
                }
            }))
            .on_click(cx.listener(move |this, _, _, cx| this.select_genome_file(path.clone(), cx)))
            .child(
                div()
                    .w(px(18.))
                    .flex_none()
                    .text_size(px(10.5))
                    .font_family(metal::MONO)
                    .text_color(p.ink3)
                    .child(format!("{}", ix + 1)),
            )
            .child(div().size(px(8.)).flex_none().rounded_full().bg(folder_color(&file.path, p)))
            .child(
                h_flex()
                    .flex_1()
                    .min_w_0()
                    .text_size(px(12.5))
                    .child(div().flex_shrink(1.).min_w_0().truncate().text_color(p.ink3).child(dir))
                    .child(div().flex_none().text_color(p.ink).child(name)),
            )
            .when(file.recent, |this| this.child(metal::tag("new", p)))
            .when(file.dependents > 0, |this| {
                this.child(
                    div()
                        .flex_none()
                        .text_size(px(11.))
                        .font_family(metal::MONO)
                        .text_color(p.ink2)
                        .child(format!("→{}", file.dependents)),
                )
            });

        v_flex().child(row).when(open, |this| {
            this.child(
                v_flex()
                    .pl(px(44.))
                    .pr(px(10.))
                    .pb(px(6.))
                    .gap(px(2.))
                    .child(
                        div().text_size(px(11.5)).text_color(p.ink3).child(format!(
                            "{} · {} lines · imported by {} · imports {}",
                            file.language, file.lines, file.dependents, file.dependencies
                        )),
                    )
                    .when(file.symbols.is_empty(), |this| {
                        this.child(div().text_size(px(12.)).text_color(p.ink3).child("No symbols"))
                    })
                    .children(file.symbols.iter().map(|symbol| {
                        let signature = symbol
                            .signature
                            .as_deref()
                            .map(|signature| signature.lines().next().unwrap_or_default().trim().to_string())
                            .filter(|signature| !signature.is_empty())
                            .unwrap_or_else(|| format!("{} {}", symbol.kind, symbol.name));
                        h_flex()
                            .gap(px(6.))
                            .text_size(px(11.5))
                            .font_family(metal::MONO)
                            .child(
                                div()
                                    .w(px(10.))
                                    .flex_none()
                                    .text_color(if symbol.exported { p.green_ink } else { p.ink3 })
                                    .child(if symbol.exported { "+" } else { " " }),
                            )
                            .child(div().flex_1().min_w_0().truncate().text_color(p.ink2).child(signature))
                            .child(div().flex_none().text_color(p.ink3).child(format!(":{}", symbol.line)))
                    }))
                    .when(file.symbol_count as usize > file.symbols.len(), |this| {
                        this.child(
                            div()
                                .text_size(px(11.5))
                                .text_color(p.ink3)
                                .child(format!("… {} more", file.symbol_count as usize - file.symbols.len())),
                        )
                    }),
            )
        })
    }
}

fn file_name(path: &str) -> String {
    path.rsplit('/').next().unwrap_or(path).to_string()
}

/// A file's color in the map: one of the palette's tints, the same for
/// every file in a folder.
fn folder_color(path: &str, p: &Palette) -> Hsla {
    let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder);
    let hash = folder.bytes().fold(7u32, |hash, byte| hash.wrapping_mul(31).wrapping_add(byte as u32));
    p.tints[hash as usize % p.tints.len()]
}

fn genome_message(title: &str, detail: String, p: &Palette) -> impl IntoElement {
    v_flex()
        .px(px(10.))
        .py(px(10.))
        .gap(px(4.))
        .child(div().text_size(px(13.)).text_color(p.ink2).child(title.to_string()))
        .when(!detail.is_empty(), |this| this.child(div().text_size(px(12.)).text_color(p.ink3).child(detail)))
}