//! 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, ..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)))
}