1use std::cell::RefCell;
7use std::collections::HashMap;
8use std::rc::Rc;
9
10use trace_stream::render::RenderOptions;
11use turbo_vision::core::event::Event;
12use turbo_vision::core::geometry::Rect;
13use turbo_vision::terminal::Terminal;
14use turbo_vision::views::view::View;
15
16use crate::pipeline::Pipeline;
17use crate::registry::SessionId;
18use crate::streamview::StreamView;
19
20pub type SharedView = Rc<RefCell<StreamView>>;
22
23#[derive(Debug)]
25pub struct SharedStreamView(pub SharedView);
26
27impl View for SharedStreamView {
28 fn bounds(&self) -> Rect {
29 self.0.borrow().bounds()
30 }
31 fn set_bounds(&mut self, bounds: Rect) {
32 self.0.borrow_mut().set_bounds(bounds);
33 }
34 fn draw(&mut self, terminal: &mut Terminal) {
35 self.0.borrow_mut().draw(terminal);
36 }
37 fn handle_event(&mut self, event: &mut Event) {
38 self.0.borrow_mut().handle_event(event);
39 }
40 fn can_focus(&self) -> bool {
41 true
42 }
43 fn get_palette(&self) -> Option<turbo_vision::core::palette::Palette> {
44 None
45 }
46}
47
48#[derive(Debug)]
50pub struct SessionState {
51 pub name: String,
52 pub port: u16,
53 pub view: SharedView,
54 pub pipeline: Pipeline,
55 pub connected: bool,
56}
57
58impl SessionState {
59 #[must_use]
61 pub fn window_title(&self) -> String {
62 let base = format_title(&self.name, self.port);
63 if self.connected {
64 base
65 } else {
66 format!("{base} [disconnected]")
67 }
68 }
69}
70
71#[must_use]
77pub fn format_title(name: &str, port: u16) -> String {
78 if port == 0 {
79 name.to_string()
80 } else {
81 format!("{name} :{port}")
82 }
83}
84
85#[derive(Debug, Default)]
87pub struct Sessions {
88 inner: HashMap<SessionId, SessionState>,
89}
90
91impl Sessions {
92 pub fn insert(
93 &mut self,
94 id: SessionId,
95 name: String,
96 port: u16,
97 view: SharedView,
98 opts: RenderOptions,
99 ) {
100 self.inner.insert(
101 id,
102 SessionState {
103 name,
104 port,
105 view,
106 pipeline: Pipeline::new(opts),
107 connected: false,
108 },
109 );
110 }
111
112 pub fn get_mut(&mut self, id: SessionId) -> Option<&mut SessionState> {
113 self.inner.get_mut(&id)
114 }
115
116 pub fn remove(&mut self, id: SessionId) -> Option<SessionState> {
117 self.inner.remove(&id)
118 }
119
120 pub fn feed(&mut self, id: SessionId, data: &[u8]) {
122 if let Some(s) = self.inner.get_mut(&id) {
123 let mut view = s.view.borrow_mut();
124 s.pipeline.feed(data, &mut view);
125 }
126 }
127
128 pub fn mark_reconnected(&mut self, id: SessionId) {
130 if let Some(s) = self.inner.get_mut(&id) {
131 s.connected = true;
132 let mut view = s.view.borrow_mut();
133 s.pipeline.feed(b"\n-- reconnected --\n", &mut view);
134 }
135 }
136
137 pub fn mark_attached(&mut self, id: SessionId, reattached: bool) {
143 if reattached {
144 self.mark_reconnected(id);
145 } else if let Some(s) = self.inner.get_mut(&id) {
146 s.connected = true;
147 }
148 }
149
150 pub fn mark_disconnected(&mut self, id: SessionId) {
151 if let Some(s) = self.inner.get_mut(&id) {
152 s.connected = false;
153 let mut view = s.view.borrow_mut();
154 s.pipeline.finish(&mut view);
155 }
156 }
157
158 pub fn set_options(&mut self, opts: RenderOptions) {
161 for s in self.inner.values_mut() {
162 let mut view = s.view.borrow_mut();
163 s.pipeline.set_options(opts, &mut view);
164 }
165 }
166
167 pub fn clear(&mut self, id: SessionId) {
169 if let Some(s) = self.inner.get_mut(&id) {
170 s.view.borrow_mut().clear();
171 }
172 }
173
174 #[must_use]
176 pub fn plain_text(&self, id: SessionId) -> Option<String> {
177 self.inner.get(&id).map(|s| s.view.borrow().plain_text())
178 }
179
180 #[must_use]
184 pub fn window_title(&self, id: SessionId) -> Option<String> {
185 self.inner.get(&id).map(SessionState::window_title)
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use trace_stream::render::RenderOptions;
193
194 fn opts() -> RenderOptions {
195 RenderOptions {
196 use_color: true,
197 format_thinking: true,
198 format_markdown: true,
199 }
200 }
201
202 fn view() -> SharedView {
203 Rc::new(RefCell::new(StreamView::new(Rect::new(0, 0, 80, 24))))
204 }
205
206 #[test]
207 fn feed_reaches_the_session_view() {
208 let mut sessions = Sessions::default();
209 sessions.insert(1, "demo".into(), 4242, view(), opts());
210 sessions.feed(1, b"hello\n");
211 assert!(sessions.plain_text(1).unwrap().contains("hello"));
212 }
213
214 #[test]
215 fn window_title_reflects_connection_state() {
216 let mut sessions = Sessions::default();
217 sessions.insert(1, "demo".into(), 4242, view(), opts());
218 assert_eq!(
219 sessions.window_title(1).unwrap(),
220 "demo :4242 [disconnected]"
221 );
222 sessions.mark_reconnected(1);
223 assert_eq!(sessions.window_title(1).unwrap(), "demo :4242");
224 sessions.mark_disconnected(1);
225 assert_eq!(
226 sessions.window_title(1).unwrap(),
227 "demo :4242 [disconnected]"
228 );
229 }
230
231 #[test]
232 fn window_title_omits_a_zero_port() {
233 let mut sessions = Sessions::default();
234 sessions.insert(1, "anon-1".into(), 0, view(), opts());
235 assert_eq!(sessions.window_title(1).unwrap(), "anon-1 [disconnected]");
236 sessions.mark_reconnected(1);
237 assert_eq!(sessions.window_title(1).unwrap(), "anon-1");
238 }
239
240 #[test]
246 fn mark_attached_first_attach_connects_without_a_rule() {
247 let mut sessions = Sessions::default();
248 sessions.insert(1, "demo".into(), 4242, view(), opts());
249 sessions.mark_attached(1, false);
250 assert_eq!(sessions.window_title(1).unwrap(), "demo :4242");
251 assert!(!sessions.plain_text(1).unwrap().contains("reconnected"));
252 }
253
254 #[test]
257 fn mark_attached_reattach_connects_and_draws_a_rule() {
258 let mut sessions = Sessions::default();
259 sessions.insert(1, "demo".into(), 4242, view(), opts());
260 sessions.mark_attached(1, true);
261 assert_eq!(sessions.window_title(1).unwrap(), "demo :4242");
262 assert!(sessions.plain_text(1).unwrap().contains("reconnected"));
263 }
264
265 #[test]
266 fn mark_reconnected_draws_a_horizontal_rule() {
267 let mut sessions = Sessions::default();
268 sessions.insert(1, "demo".into(), 4242, view(), opts());
269 sessions.mark_reconnected(1);
270 assert!(sessions.plain_text(1).unwrap().contains("reconnected"));
271 }
272
273 #[test]
274 fn clear_empties_the_scrollback() {
275 let mut sessions = Sessions::default();
276 sessions.insert(1, "demo".into(), 4242, view(), opts());
277 sessions.feed(1, b"hello\n");
278 sessions.clear(1);
279 assert_eq!(sessions.plain_text(1).unwrap(), "");
280 }
281
282 #[test]
283 fn set_options_applies_to_every_session() {
284 let mut sessions = Sessions::default();
285 sessions.insert(1, "demo".into(), 4242, view(), opts());
286 let mut new_opts = opts();
287 new_opts.format_markdown = false;
288 sessions.set_options(new_opts);
289 assert!(
290 !sessions
291 .get_mut(1)
292 .unwrap()
293 .pipeline
294 .options()
295 .format_markdown
296 );
297 }
298
299 #[test]
300 fn remove_drops_the_session() {
301 let mut sessions = Sessions::default();
302 sessions.insert(1, "demo".into(), 4242, view(), opts());
303 assert!(sessions.remove(1).is_some());
304 assert!(sessions.plain_text(1).is_none());
305 }
306
307 #[test]
308 fn unknown_id_returns_none_everywhere() {
309 let sessions = Sessions::default();
310 assert!(sessions.plain_text(99).is_none());
311 assert!(sessions.window_title(99).is_none());
312 }
313}