//! The Genome map: Empryo's ranked map of the open thread's checkout, read
//! from the `.empryo/genome.db` Empryo keeps there. 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::{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::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 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);
/// 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 {
/// The checkout it maps.
cwd: PathBuf,
load: GenomeLoad,
/// When the database was last written, as of the last read.
read_at: Option<SystemTime>,
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 in
/// a checkout on this device, where its Genome can be read.
pub(super) fn genome_available(&self, cx: &App) -> bool {
let local = self.active_project().is_some_and(|project| project.environment.is_none());
let provider = match &self.active {
Some(session) => {
let session = session.read(cx);
if session.remote.is_some() {
return false;
}
session.provider
}
None => self.current_settings(cx).provider,
};
local && provider == Provider::Empryo
}
/// The checkout the open thread works in.
fn genome_cwd(&self, cx: &App) -> Option<PathBuf> {
match &self.active {
Some(session) => Some(session.read(cx).cwd.clone()),
None => self.active_project().map(|project| 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(cwd) = self.genome_cwd(cx) else { return };
if self.genome.as_ref().is_some_and(|panel| panel.cwd == cwd) {
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;
}
let written = written_at(&panel.cwd);
if written != panel.read_at {
this.read_genome(cx);
}
});
if alive.is_err() {
return;
}
}
});
self.genome = Some(GenomePanel {
cwd,
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.
fn read_genome(&mut self, cx: &mut Context<Self>) {
let Some(panel) = self.genome.as_mut() else { return };
panel.reading = true;
let cwd = panel.cwd.clone();
let read = cx.background_executor().spawn({
let cwd = cwd.clone();
async move { (written_at(&cwd), genome::load(&cwd)) }
});
cx.spawn(async move |this, cx| {
let (written, result) = read.await;
let _ = this.update(cx, |this, cx| {
let Some(panel) = this.genome.as_mut().filter(|panel| panel.cwd == cwd) else { return };
panel.reading = false;
panel.read_at = written;
panel.hovered = None;
panel.load = match result {
Ok(Some(genome)) => GenomeLoad::Ready(Arc::new(genome)),
Ok(None) => GenomeLoad::Missing,
Err(err) => GenomeLoad::Failed(format!("{err:#}")),
};
cx.notify();
});
})
.detach();
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(crate::clock::when);
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())),
)
}),
)
})
}
}
/// When Empryo last wrote the Genome of the checkout `cwd` is in: its
/// write-ahead log or the database itself.
fn written_at(cwd: &Path) -> Option<SystemTime> {
let path = genome::db_path(cwd)?;
[path.with_extension("db-wal"), path]
.iter()
.filter_map(|path| std::fs::metadata(path).and_then(|meta| meta.modified()).ok())
.max()
}
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)))
}