use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use gpui_kit::{App, AppContext as _, Entity, SharedString};
use kitchen_ui::timeline::{Activity, Timeline};
use serde_json::Value;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Actor {
Person,
Agent,
}
impl Actor {
pub fn lane(self) -> &'static str {
match self {
Actor::Person => "You",
Actor::Agent => "Agent",
}
}
}
#[derive(Default)]
struct Log {
items: Vec<Activity>,
open: HashMap<String, usize>,
workspace: String,
}
static LOG: Mutex<Option<Log>> = Mutex::new(None);
static GENERATION: AtomicU64 = AtomicU64::new(0);
fn with<T>(f: impl FnOnce(&mut Log) -> T) -> T {
let mut g = LOG.lock().unwrap_or_else(|e| e.into_inner());
let out = f(g.get_or_insert_with(Log::default));
GENERATION.fetch_add(1, Ordering::Relaxed);
out
}
pub fn now() -> f64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_secs_f64())
}
pub fn generation() -> u64 {
GENERATION.load(Ordering::Relaxed)
}
pub fn record(a: Activity) {
with(|l| l.items.push(a));
}
pub fn open(key: impl Into<String>, mut a: Activity) {
let key = key.into();
let t = now();
with(|l| {
if let Some(i) = l.open.remove(&key) {
l.items[i].end = t;
}
a.start = t;
a.end = t;
l.items.push(a);
l.open.insert(key, l.items.len() - 1);
});
}
pub fn close(key: &str) {
let t = now();
with(|l| {
if let Some(i) = l.open.remove(key) {
l.items[i].end = t.max(l.items[i].start + 1.0);
}
});
}
fn short(v: &Value, max: usize) -> String {
let s = v.to_string();
if s.chars().count() <= max { s } else { format!("{}โฆ", s.chars().take(max).collect::<String>()) }
}
pub fn command(actor: Actor, id: &str, title: &str, group: &str, via: &str, args: &Value, result: &Result<Value, String>) {
let mut detail = Vec::new();
if !args.is_null() && args != &Value::Object(Default::default()) {
detail.push(format!("{id} {}", short(args, 120)));
} else {
detail.push(id.to_string());
}
match result {
Ok(Value::Null) => {}
Ok(v) => detail.push(format!("โ {}", short(v, 120))),
Err(e) => detail.push(format!("refused: {e}")),
}
let t = now();
with(|l| {
let source = if l.workspace.is_empty() { via.to_string() } else { format!("{} ยท {via}", l.workspace) };
l.items.push(Activity {
start: t,
end: t,
lane: actor.lane().into(),
kind: if group.is_empty() { "Commands".into() } else { SharedString::from(group.to_string()) },
title: title.to_string().into(),
detail: detail.join(" ").into(),
source: source.into(),
assets: Vec::new(),
})
});
}
pub fn focus(workspace: &str) {
let name = workspace.to_string();
with(|l| l.workspace = name.clone());
open("focus", Activity { lane: "Focus".into(), kind: SharedString::from(name.clone()), title: format!("In {name}").into(), ..Default::default() });
}
pub fn job_started(id: u64, kind: &str, name: &str) {
open(format!("job-{id}"), Activity { lane: "Jobs".into(), kind: kind.to_string().into(), title: name.to_string().into(), source: "job".into(), ..Default::default() });
}
pub fn job_ended(id: u64) {
close(&format!("job-{id}"));
}
pub fn produced(title: &str, assets: Vec<String>) {
let t = now();
with(|l| match l.items.iter_mut().rev().find(|a| a.title == title) {
Some(a) => a.assets.extend(assets.into_iter().map(SharedString::from)),
None => l.items.push(Activity { start: t, end: t, lane: Actor::Person.lane().into(), kind: "Output".into(), title: title.to_string().into(), assets: assets.into_iter().map(SharedString::from).collect(), ..Default::default() }),
});
}
pub fn all() -> Vec<Activity> {
let t = now();
let g = LOG.lock().unwrap_or_else(|e| e.into_inner());
let Some(l) = g.as_ref() else { return Vec::new() };
let mut out = l.items.clone();
for &i in l.open.values() {
out[i].end = t.max(out[i].start + 1.0);
}
out
}
pub fn clear() {
with(|l| {
let ws = std::mem::take(&mut l.workspace);
*l = Log::default();
l.workspace = ws;
});
}
pub fn view(title: &str, cx: &mut App) -> Entity<Timeline> {
let timeline = cx.new(|_| Timeline::new(title.to_string()));
timeline.update(cx, |t, cx| t.set_activities(all(), now(), cx));
let weak = timeline.downgrade();
cx.spawn(async move |cx| {
let mut seen = generation();
loop {
cx.background_executor().timer(std::time::Duration::from_secs(1)).await;
let g = generation();
let stale = g != seen;
seen = g;
let alive = weak.update(cx, |t, cx| {
if stale || !t.activities().is_empty() {
t.set_activities(all(), now(), cx)
}
});
if alive.is_err() {
break;
}
}
})
.detach();
timeline
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commands_spans_and_jobs_are_recorded() {
clear();
focus("Explore");
command(Actor::Person, "hist.find_cells", "Find cells", "Analyze", "menu", &Value::Null, &Ok(serde_json::json!({"cells": 12})));
command(Actor::Agent, "process.op.blur", "Gaussian blur", "Filter", "agent", &serde_json::json!({"sigma": 2}), &Err("Open an image first".into()));
job_started(7, "histology", "Finding cells");
produced("Find cells", vec!["cells.parquet".into()]);
let a = all();
let find = a.iter().find(|x| x.title == "Find cells").unwrap();
assert_eq!((find.lane.as_ref(), find.kind.as_ref(), find.source.as_ref()), ("You", "Analyze", "Explore ยท menu"));
assert!(find.detail.contains("cells"));
assert_eq!(find.assets, vec![SharedString::from("cells.parquet")]);
let blur = a.iter().find(|x| x.title == "Gaussian blur").unwrap();
assert_eq!(blur.lane.as_ref(), "Agent");
assert!(blur.detail.contains("refused"));
assert!(a.iter().any(|x| x.lane == "Jobs" && x.end > x.start), "a running job lasts until now");
job_ended(7);
focus("Analyze");
let a = all();
assert_eq!(a.iter().filter(|x| x.lane == "Focus").count(), 2);
assert!(a.iter().filter(|x| x.lane == "Focus").all(|x| x.end > x.start));
}
}