use std::path::{Path, PathBuf};
use hifitime::{Duration as HifiDuration, Epoch};
use parking_lot::Mutex;
use GORBIE::card_ctx::GRID_ROW_MODULE;
use GORBIE::prelude::CardCtx;
use triblespace::core::id::Id;
use triblespace::core::metadata;
use triblespace::core::repo::pile::Pile;
use triblespace::core::repo::{
ancestors, BlobStore, BlobStoreGet, BranchStore, CommitSelector, CommitSet, Repository,
Workspace,
};
use triblespace::core::trible::TribleSet;
use triblespace::core::value::schemas::hash::{Blake3, Handle};
use triblespace::core::value::Value;
use triblespace::macros::{find, pattern};
use triblespace::prelude::blobschemas::{LongString, SimpleArchive};
use triblespace::prelude::View;
type TextHandle = Value<Handle<Blake3, LongString>>;
type CommitHandleValue = Value<Handle<Blake3, SimpleArchive>>;
const DEFAULT_VIEWPORT_HEIGHT: f32 = 800.0;
const TIMELINE_DEFAULT_SCALE: f32 = 2.0;
const TICK_INTERVALS: &[i128] = {
const NS: i128 = 1_000_000_000;
&[
NS, 5 * NS, 10 * NS, 30 * NS, 60 * NS, 5 * 60 * NS, 10 * 60 * NS, 30 * 60 * NS, 3600 * NS, 3 * 3600 * NS, 6 * 3600 * NS, 12 * 3600 * NS, 86400 * NS, 7 * 86400 * NS, ]
};
fn format_time_marker(key: i128) -> String {
let ns = HifiDuration::from_total_nanoseconds(key);
let epoch = Epoch::from_tai_duration(ns);
let (y, m, d, h, min, s, _) = epoch.to_gregorian_utc();
format!("{y:04}-{m:02}-{d:02} {h:02}:{min:02}:{s:02}")
}
fn now_key() -> i128 {
Epoch::now()
.map(|e| e.to_tai_duration().total_nanoseconds())
.unwrap_or(0)
}
struct TimelineLive {
ws: Workspace<Pile<Blake3>>,
commits: Vec<CommitEntry>,
}
#[derive(Clone, Debug)]
struct CommitEntry {
commit_id: Id,
ts_ns: i128,
}
impl TimelineLive {
fn open(path: &Path, branch_name: &str) -> Result<Self, String> {
let mut pile = Pile::<Blake3>::open(path).map_err(|e| format!("open pile: {e:?}"))?;
if let Err(err) = pile.restore() {
let _ = pile.close();
return Err(format!("restore: {err:?}"));
}
let signing_key = ed25519_dalek::SigningKey::generate(&mut rand_core06::OsRng);
let mut repo = Repository::new(pile, signing_key, TribleSet::new())
.map_err(|e| format!("repo: {e:?}"))?;
repo.storage_mut()
.refresh()
.map_err(|e| format!("refresh: {e:?}"))?;
let bid = find_branch(&mut repo, branch_name)
.ok_or_else(|| format!("no '{branch_name}' branch found"))?;
let mut ws = repo.pull(bid).map_err(|e| format!("pull: {e:?}"))?;
let commits = enumerate_commits(&mut ws)?;
Ok(TimelineLive { ws, commits })
}
fn refresh(&mut self) {
if let Ok(cs) = enumerate_commits(&mut self.ws) {
self.commits = cs;
}
}
}
fn enumerate_commits(
ws: &mut Workspace<Pile<Blake3>>,
) -> Result<Vec<CommitEntry>, String> {
let Some(head) = ws.head() else {
return Ok(Vec::new());
};
let set: CommitSet = ancestors(head)
.select(ws)
.map_err(|e| format!("ancestors select: {e:?}"))?;
let mut out: Vec<CommitEntry> = Vec::new();
for raw in set.iter() {
let handle: CommitHandleValue = Value::new(*raw);
let Ok(meta) = ws.get::<TribleSet, SimpleArchive>(handle) else {
continue;
};
if let Some((cid, ts)) = find!(
(cid: Id, ts: (i128, i128)),
pattern!(&meta, [{ ?cid @ metadata::created_at: ?ts }])
)
.next()
{
out.push(CommitEntry {
commit_id: cid,
ts_ns: ts.0,
});
}
}
out.sort_by_key(|c| c.ts_ns);
Ok(out)
}
fn find_branch(repo: &mut Repository<Pile<Blake3>>, name: &str) -> Option<Id> {
let reader = repo.storage_mut().reader().ok()?;
for item in repo.storage_mut().branches().ok()? {
let bid = item.ok()?;
let head = repo.storage_mut().head(bid).ok()??;
let meta: TribleSet = reader.get(head).ok()?;
let branch_name = find!(
(h: TextHandle),
pattern!(&meta, [{ metadata::name: ?h }])
)
.into_iter()
.next()
.and_then(|(h,)| reader.get::<View<str>, LongString>(h).ok())
.map(|v: View<str>| {
let s: &str = v.as_ref();
s.to_string()
});
if branch_name.as_deref() == Some(name) {
return Some(bid);
}
}
None
}
pub struct BranchTimeline {
pile_path: PathBuf,
branch_name: String,
viewport_height: f32,
live: Option<Mutex<TimelineLive>>,
error: Option<String>,
timeline_start: i128,
timeline_scale: f32,
first_render: bool,
}
impl BranchTimeline {
pub fn new(pile_path: impl Into<PathBuf>, branch_name: impl Into<String>) -> Self {
Self {
pile_path: pile_path.into(),
branch_name: branch_name.into(),
viewport_height: DEFAULT_VIEWPORT_HEIGHT,
live: None,
error: None,
timeline_start: 0,
timeline_scale: TIMELINE_DEFAULT_SCALE,
first_render: true,
}
}
pub fn with_height(mut self, height: f32) -> Self {
self.viewport_height = height.max(48.0);
self
}
pub fn render(&mut self, ctx: &mut CardCtx<'_>) {
if self.live.is_none() && self.error.is_none() {
match TimelineLive::open(&self.pile_path, &self.branch_name) {
Ok(live) => self.live = Some(Mutex::new(live)),
Err(e) => self.error = Some(e),
}
}
if let Some(err) = &self.error {
ctx.label(format!("branch timeline error: {err}"));
return;
}
let Some(live_lock) = self.live.as_ref() else {
ctx.label("branch timeline not initialized");
return;
};
let now = now_key();
if self.first_render {
self.timeline_start = now;
self.first_render = false;
live_lock.lock().refresh();
}
let commits: Vec<CommitEntry> = live_lock.lock().commits.clone();
let branch_name = self.branch_name.clone();
let viewport_height = self.viewport_height;
ctx.section(&format!("Branch: {branch_name}"), |ctx| {
ctx.label(format!("{} commits", commits.len()));
self.paint_viewport(ctx, viewport_height, now, &commits);
});
}
fn paint_viewport(
&mut self,
ctx: &mut CardCtx<'_>,
viewport_height: f32,
now: i128,
commits: &[CommitEntry],
) {
let ui = ctx.ui_mut();
let scroll_speed = 3.0;
let viewport_width = ui.available_width();
let (viewport_rect, viewport_response) = ui.allocate_exact_size(
egui::vec2(viewport_width, viewport_height),
egui::Sense::click_and_drag(),
);
{
let ns_per_px = 60_000_000_000.0 / self.timeline_scale as f64;
if viewport_response.hovered() {
let (scroll_y, scroll_x, ctrl, pointer_pos) = ui.input(|i| {
(
i.smooth_scroll_delta.y,
i.smooth_scroll_delta.x,
i.modifiers.command || i.modifiers.ctrl,
i.pointer.hover_pos(),
)
});
let cursor_rel_y = pointer_pos
.map(|p| (p.y - viewport_rect.top()).max(0.0))
.unwrap_or(viewport_height * 0.5);
let cursor_time =
self.timeline_start - (cursor_rel_y as f64 * ns_per_px) as i128;
if !ctrl && scroll_y != 0.0 {
let pan_ns = (scroll_y as f64 * scroll_speed * ns_per_px) as i128;
self.timeline_start += pan_ns;
}
let zoom_factor = if ctrl && scroll_y != 0.0 {
if scroll_y > 0.0 {
1.15
} else {
1.0 / 1.15
}
} else if scroll_x != 0.0 {
if scroll_x > 0.0 {
1.08
} else {
1.0 / 1.08
}
} else {
1.0
};
if zoom_factor != 1.0 {
let new_scale = (self.timeline_scale * zoom_factor).clamp(0.01, 1000.0);
let new_ns_per_px = 60_000_000_000.0 / new_scale as f64;
self.timeline_start =
cursor_time + (cursor_rel_y as f64 * new_ns_per_px) as i128;
self.timeline_scale = new_scale;
}
ui.ctx().input_mut(|i| {
i.smooth_scroll_delta = egui::Vec2::ZERO;
});
}
if viewport_response.dragged() {
let drag_delta = viewport_response.drag_delta().y;
let pan_ns = (drag_delta as f64 * ns_per_px) as i128;
self.timeline_start += pan_ns;
}
if viewport_response.double_clicked() {
self.timeline_start = now;
}
}
let ns_per_px = 60_000_000_000.0 / self.timeline_scale as f64;
let viewport_ns = (viewport_height as f64 * ns_per_px) as i128;
let view_start = self.timeline_start;
let view_end = view_start - viewport_ns;
let painter = ui.painter_at(viewport_rect);
painter.rect_filled(
viewport_rect,
0.0,
egui::Color32::from_rgb(0x29, 0x2c, 0x2f),
);
let muted = egui::Color32::from_rgb(0x8a, 0x8a, 0x8a);
let axis_color = egui::Color32::from_rgb(0xbd, 0xbd, 0xbd);
let max_len = 80.0;
let tick_spacing_px = GRID_ROW_MODULE;
let tau = std::f64::consts::TAU;
let ns = 1_000_000_000.0f64;
let periods = [60.0 * ns, 3600.0 * ns, 86400.0 * ns];
let significance = |t: f64| -> f32 {
let mut sig = 0.0f32;
let mut n = 0.0f32;
for &period in &periods {
let px_wave = period / ns_per_px;
let vis = ((px_wave as f32 / tick_spacing_px - 1.0) / 3.0).clamp(0.0, 1.0);
if vis < 0.001 {
continue;
}
sig += vis * (0.5 + 0.5 * (tau * t / period).cos() as f32);
n += vis;
}
if n > 0.0 {
sig / n
} else {
0.0
}
};
let n_samples = (viewport_height / tick_spacing_px) as usize + 1;
for i in 0..=n_samples {
let y = viewport_rect.top() + i as f32 * tick_spacing_px;
if y > viewport_rect.bottom() {
break;
}
let t = view_start as f64 - (i as f64 * tick_spacing_px as f64 * ns_per_px);
let sig = significance(t);
let tick_len = 2.0 + (max_len - 2.0) * sig;
painter.line_segment(
[
egui::pos2(viewport_rect.left(), y),
egui::pos2(viewport_rect.left() + tick_len, y),
],
egui::Stroke::new(0.5, muted),
);
}
let label_min_spacing_px = 100.0;
let label_min_ns = (label_min_spacing_px as f64 * ns_per_px) as i128;
let label_interval = TICK_INTERVALS
.iter()
.copied()
.find(|&iv| iv >= label_min_ns)
.unwrap_or(*TICK_INTERVALS.last().unwrap());
if label_interval > 0 {
let first = (view_start / label_interval) * label_interval;
let mut tick = first;
while tick > view_end {
let y = viewport_rect.top()
+ ((view_start - tick) as f64 / ns_per_px) as f32;
if y >= viewport_rect.top() && y <= viewport_rect.bottom() {
let label = format_time_marker(tick);
painter.text(
egui::pos2(viewport_rect.left() + max_len + 4.0, y),
egui::Align2::LEFT_CENTER,
&label,
egui::FontId::monospace(9.0),
muted,
);
}
tick -= label_interval;
}
}
let commit_color = egui::Color32::from_rgb(0xff, 0xc8, 0x3a);
let axis_x = viewport_rect.right() - 40.0;
painter.line_segment(
[
egui::pos2(axis_x, viewport_rect.top()),
egui::pos2(axis_x, viewport_rect.bottom()),
],
egui::Stroke::new(0.5, axis_color),
);
let pointer_pos = ui.input(|i| i.pointer.hover_pos());
let mut hover_label: Option<(egui::Pos2, String)> = None;
for c in commits {
if c.ts_ns < view_end || c.ts_ns > view_start {
continue;
}
let y = viewport_rect.top() + ((view_start - c.ts_ns) as f64 / ns_per_px) as f32;
let x1 = axis_x - 8.0;
let x2 = axis_x + 8.0;
painter.line_segment(
[egui::pos2(x1, y), egui::pos2(x2, y)],
egui::Stroke::new(1.5, commit_color),
);
painter.circle_filled(egui::pos2(axis_x, y), 2.5, commit_color);
if let Some(p) = pointer_pos {
if viewport_rect.contains(p) && (p.y - y).abs() <= 4.0 && (p.x - axis_x).abs() <= 40.0
{
let short = format!("{:x}", c.commit_id);
let short = if short.len() > 8 {
short[..8].to_string()
} else {
short
};
let label = format!("{} {}", short, format_time_marker(c.ts_ns));
hover_label = Some((egui::pos2(axis_x - 12.0, y), label));
}
}
}
if let Some((pos, label)) = hover_label {
painter.text(
pos,
egui::Align2::RIGHT_CENTER,
label,
egui::FontId::monospace(10.0),
axis_color,
);
}
}
}