//! 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, 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), } 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, reading: bool, /// The file opened in the list, by path, so it stays open across reloads. selected: Option, hovered: Option, _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 { 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) { 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) { 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) { 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.toggle_surface(Surface::Genome, window, cx); } fn select_genome_file(&mut self, path: String, cx: &mut Context) { 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) -> 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, p: &Palette, cx: &mut Context) -> 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 = 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, focus: Option, p: &Palette, cx: &mut Context) -> 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 = 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> = 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 = (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) -> 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))) }