1use std::collections::HashMap;
11use std::sync::Mutex;
12use std::sync::atomic::{AtomicU64, Ordering};
13
14use gpui_kit::{App, AppContext as _, Entity, SharedString};
15use kitchen_ui::timeline::{Activity, Timeline};
16use serde_json::Value;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Actor {
21 Person,
23 Agent,
25}
26
27impl Actor {
28 pub fn lane(self) -> &'static str {
30 match self {
31 Actor::Person => "You",
32 Actor::Agent => "Agent",
33 }
34 }
35}
36
37#[derive(Default)]
38struct Log {
39 items: Vec<Activity>,
40 open: HashMap<String, usize>,
42 workspace: String,
43}
44
45static LOG: Mutex<Option<Log>> = Mutex::new(None);
46static GENERATION: AtomicU64 = AtomicU64::new(0);
47
48fn with<T>(f: impl FnOnce(&mut Log) -> T) -> T {
49 let mut g = LOG.lock().unwrap_or_else(|e| e.into_inner());
50 let out = f(g.get_or_insert_with(Log::default));
51 GENERATION.fetch_add(1, Ordering::Relaxed);
52 out
53}
54
55pub fn now() -> f64 {
57 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_secs_f64())
58}
59
60pub fn generation() -> u64 {
62 GENERATION.load(Ordering::Relaxed)
63}
64
65pub fn record(a: Activity) {
67 with(|l| l.items.push(a));
68}
69
70pub fn open(key: impl Into<String>, mut a: Activity) {
73 let key = key.into();
74 let t = now();
75 with(|l| {
76 if let Some(i) = l.open.remove(&key) {
77 l.items[i].end = t;
78 }
79 a.start = t;
80 a.end = t;
81 l.items.push(a);
82 l.open.insert(key, l.items.len() - 1);
83 });
84}
85
86pub fn close(key: &str) {
88 let t = now();
89 with(|l| {
90 if let Some(i) = l.open.remove(key) {
91 l.items[i].end = t.max(l.items[i].start + 1.0);
92 }
93 });
94}
95
96fn short(v: &Value, max: usize) -> String {
98 let s = v.to_string();
99 if s.chars().count() <= max { s } else { format!("{}…", s.chars().take(max).collect::<String>()) }
100}
101
102pub fn command(actor: Actor, id: &str, title: &str, group: &str, via: &str, args: &Value, result: &Result<Value, String>) {
105 let mut detail = Vec::new();
106 if !args.is_null() && args != &Value::Object(Default::default()) {
107 detail.push(format!("{id} {}", short(args, 120)));
108 } else {
109 detail.push(id.to_string());
110 }
111 match result {
112 Ok(Value::Null) => {}
113 Ok(v) => detail.push(format!("→ {}", short(v, 120))),
114 Err(e) => detail.push(format!("refused: {e}")),
115 }
116 let t = now();
117 with(|l| {
118 let source = if l.workspace.is_empty() { via.to_string() } else { format!("{} · {via}", l.workspace) };
119 l.items.push(Activity {
120 start: t,
121 end: t,
122 lane: actor.lane().into(),
123 kind: if group.is_empty() { "Commands".into() } else { SharedString::from(group.to_string()) },
124 title: title.to_string().into(),
125 detail: detail.join(" ").into(),
126 source: source.into(),
127 assets: Vec::new(),
128 })
129 });
130}
131
132pub fn focus(workspace: &str) {
134 let name = workspace.to_string();
135 with(|l| l.workspace = name.clone());
136 open("focus", Activity { lane: "Focus".into(), kind: SharedString::from(name.clone()), title: format!("In {name}").into(), ..Default::default() });
137}
138
139pub fn job_started(id: u64, kind: &str, name: &str) {
141 open(format!("job-{id}"), Activity { lane: "Jobs".into(), kind: kind.to_string().into(), title: name.to_string().into(), source: "job".into(), ..Default::default() });
142}
143
144pub fn job_ended(id: u64) {
146 close(&format!("job-{id}"));
147}
148
149pub fn produced(title: &str, assets: Vec<String>) {
152 let t = now();
153 with(|l| match l.items.iter_mut().rev().find(|a| a.title == title) {
154 Some(a) => a.assets.extend(assets.into_iter().map(SharedString::from)),
155 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() }),
156 });
157}
158
159pub fn all() -> Vec<Activity> {
161 let t = now();
162 let g = LOG.lock().unwrap_or_else(|e| e.into_inner());
163 let Some(l) = g.as_ref() else { return Vec::new() };
164 let mut out = l.items.clone();
165 for &i in l.open.values() {
166 out[i].end = t.max(out[i].start + 1.0);
167 }
168 out
169}
170
171pub fn clear() {
173 with(|l| {
174 let ws = std::mem::take(&mut l.workspace);
175 *l = Log::default();
176 l.workspace = ws;
177 });
178}
179
180pub fn view(title: &str, cx: &mut App) -> Entity<Timeline> {
182 let timeline = cx.new(|_| Timeline::new(title.to_string()));
183 timeline.update(cx, |t, cx| t.set_activities(all(), now(), cx));
184 let weak = timeline.downgrade();
185 cx.spawn(async move |cx| {
186 let mut seen = generation();
187 loop {
188 cx.background_executor().timer(std::time::Duration::from_secs(1)).await;
189 let g = generation();
190 let stale = g != seen;
192 seen = g;
193 let alive = weak.update(cx, |t, cx| {
194 if stale || !t.activities().is_empty() {
195 t.set_activities(all(), now(), cx)
196 }
197 });
198 if alive.is_err() {
199 break;
200 }
201 }
202 })
203 .detach();
204 timeline
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
213 fn commands_spans_and_jobs_are_recorded() {
214 clear();
215 focus("Explore");
216 command(Actor::Person, "hist.find_cells", "Find cells", "Analyze", "menu", &Value::Null, &Ok(serde_json::json!({"cells": 12})));
217 command(Actor::Agent, "process.op.blur", "Gaussian blur", "Filter", "agent", &serde_json::json!({"sigma": 2}), &Err("Open an image first".into()));
218 job_started(7, "histology", "Finding cells");
219 produced("Find cells", vec!["cells.parquet".into()]);
220 let a = all();
221 let find = a.iter().find(|x| x.title == "Find cells").unwrap();
222 assert_eq!((find.lane.as_ref(), find.kind.as_ref(), find.source.as_ref()), ("You", "Analyze", "Explore · menu"));
223 assert!(find.detail.contains("cells"));
224 assert_eq!(find.assets, vec![SharedString::from("cells.parquet")]);
225 let blur = a.iter().find(|x| x.title == "Gaussian blur").unwrap();
226 assert_eq!(blur.lane.as_ref(), "Agent");
227 assert!(blur.detail.contains("refused"));
228 assert!(a.iter().any(|x| x.lane == "Jobs" && x.end > x.start), "a running job lasts until now");
229 job_ended(7);
230 focus("Analyze");
231 let a = all();
232 assert_eq!(a.iter().filter(|x| x.lane == "Focus").count(), 2);
233 assert!(a.iter().filter(|x| x.lane == "Focus").all(|x| x.end > x.start));
234 }
235}