1use super::commands::{
2 CommandRuntime, CommandSessionId, CommandSnapshot, UiCommand, DEFAULT_SCROLLBACK_LINES,
3};
4use super::density::UiDensity;
5use super::routes::UiRoute;
6use super::theme::UiThemeMode;
7use fission::core::{Env, RuntimeState};
8use fission::prelude::GlobalState;
9use fission::WidgetId;
10use fission_command_core::{read_project_config, Target};
11use fission_command_run as workflow;
12use std::path::PathBuf;
13
14const LOG_SCROLL_NODE_ID_PREFIX: &str = "cli_ui_log_scrollback";
15
16#[derive(Clone, Debug, PartialEq)]
17pub struct UiState {
18 pub project_dir: PathBuf,
19 pub project_name: String,
20 pub app_id: String,
21 pub project_status: String,
22 pub targets: Vec<Target>,
23 pub devices: Vec<UiDevice>,
24 pub route: UiRoute,
25 pub theme_mode: UiThemeMode,
26 pub compact_mode: bool,
27 pub selected_target: Option<Target>,
28 pub selected_device: Option<String>,
29 pub init_name: String,
30 pub init_app_id: String,
31 pub init_local_path: String,
32 pub host: String,
33 pub port: String,
34 pub strict: bool,
35 pub release: bool,
36 pub detach: bool,
37 pub no_open: bool,
38 pub headless: bool,
39 pub command_runtime: CommandRuntime,
40 pub command_sessions: Vec<CommandSnapshot>,
41 pub active_command_session_id: Option<CommandSessionId>,
42 pub last_active_log_line_count: usize,
43 pub refreshed_finished_sessions: Vec<CommandSessionId>,
44 pub scrollback_limit: usize,
45 pub scrollback_limit_input: String,
46 pub pending_dialog: Option<UiDialog>,
47 pub exit_confirmed: bool,
48}
49
50impl GlobalState for UiState {}
51
52impl Default for UiState {
53 fn default() -> Self {
54 Self {
55 project_dir: PathBuf::new(),
56 project_name: String::new(),
57 app_id: String::new(),
58 project_status: String::new(),
59 targets: Vec::new(),
60 devices: Vec::new(),
61 route: UiRoute::default(),
62 theme_mode: UiThemeMode::default(),
63 compact_mode: true,
64 selected_target: None,
65 selected_device: None,
66 init_name: String::new(),
67 init_app_id: String::new(),
68 init_local_path: String::new(),
69 host: String::new(),
70 port: String::new(),
71 strict: false,
72 release: false,
73 detach: false,
74 no_open: false,
75 headless: false,
76 command_runtime: CommandRuntime::default(),
77 command_sessions: Vec::new(),
78 active_command_session_id: None,
79 last_active_log_line_count: 0,
80 refreshed_finished_sessions: Vec::new(),
81 scrollback_limit: DEFAULT_SCROLLBACK_LINES,
82 scrollback_limit_input: DEFAULT_SCROLLBACK_LINES.to_string(),
83 pending_dialog: None,
84 exit_confirmed: false,
85 }
86 }
87}
88
89impl UiState {
90 pub fn load(project_dir: PathBuf) -> Self {
91 let mut state = Self {
92 project_dir,
93 route: UiRoute::Dashboard,
94 theme_mode: UiThemeMode::Dark,
95 host: "127.0.0.1".to_string(),
96 port: "8123".to_string(),
97 scrollback_limit: DEFAULT_SCROLLBACK_LINES,
98 scrollback_limit_input: DEFAULT_SCROLLBACK_LINES.to_string(),
99 detach: true,
100 ..Default::default()
101 };
102 state.refresh();
103 state
104 }
105
106 pub fn refresh(&mut self) {
107 match read_project_config(&self.project_dir) {
108 Ok(project) => {
109 self.project_name = project.app.name;
110 self.app_id = project.app.app_id;
111 self.targets = project.targets.iter().copied().collect();
112 self.project_status = "Project loaded".to_string();
113 if self.selected_target.is_none()
114 || self
115 .selected_target
116 .is_some_and(|target| !self.targets.contains(&target))
117 {
118 self.selected_target = preferred_target(&self.targets);
119 }
120 }
121 Err(error) => {
122 self.project_name = self
123 .project_dir
124 .file_name()
125 .and_then(|value| value.to_str())
126 .unwrap_or("workspace")
127 .to_string();
128 self.app_id = "Not initialised".to_string();
129 self.targets.clear();
130 self.selected_target = None;
131 self.project_status = format!("Project not initialised: {error}");
132 }
133 }
134
135 self.devices = workflow::discover_devices(&self.project_dir)
136 .into_iter()
137 .map(UiDevice::from)
138 .collect();
139 if self.selected_device.is_none()
140 || self
141 .selected_device
142 .as_ref()
143 .is_some_and(|selected| !self.devices.iter().any(|device| &device.id == selected))
144 {
145 self.selected_device = self
146 .devices
147 .iter()
148 .find(|device| {
149 self.selected_target
150 .map(|target| target == device.target)
151 .unwrap_or(true)
152 && device.available
153 })
154 .map(|device| device.id.clone());
155 }
156 }
157
158 pub fn selected_target_label(&self) -> String {
159 self.selected_target
160 .map(Target::as_str)
161 .unwrap_or("none")
162 .to_string()
163 }
164
165 pub fn selected_device_label(&self) -> String {
166 self.selected_device
167 .as_deref()
168 .unwrap_or("auto")
169 .to_string()
170 }
171
172 pub fn target_devices(&self) -> Vec<&UiDevice> {
173 self.devices
174 .iter()
175 .filter(|device| {
176 self.selected_target
177 .map(|target| target == device.target)
178 .unwrap_or(true)
179 })
180 .collect()
181 }
182
183 pub fn poll_command_status(&mut self, runtime: &mut RuntimeState, env: &Env) -> bool {
184 let snapshot = self.command_runtime.snapshot();
185 let mut changed = false;
186
187 let active_session = snapshot
188 .active_session_id
189 .and_then(|id| snapshot.sessions.iter().find(|item| item.id == id));
190 let active_line_count = active_session
191 .map(|item| item.record.output.display_line_count())
192 .unwrap_or(0);
193 if self.active_command_session_id != snapshot.active_session_id
194 || self.command_sessions != snapshot.sessions
195 {
196 let should_follow = should_follow_log_output(
197 self,
198 runtime,
199 env,
200 snapshot.active_session_id,
201 active_line_count,
202 );
203 self.command_sessions = snapshot.sessions.clone();
204 self.active_command_session_id = snapshot.active_session_id;
205 self.last_active_log_line_count = active_line_count;
206 if should_follow {
207 stick_log_scroll_to_bottom(
208 runtime,
209 env,
210 snapshot.active_session_id,
211 active_line_count,
212 self.compact_mode,
213 );
214 }
215 changed = true;
216 }
217
218 for session in snapshot.sessions.iter().filter(|session| session.finished) {
219 if !self.refreshed_finished_sessions.contains(&session.id) {
220 self.refreshed_finished_sessions.push(session.id);
221 self.refresh();
222 changed = true;
223 }
224 }
225 changed
226 }
227
228 pub fn sync_command_sessions(&mut self) {
229 let snapshot = self.command_runtime.snapshot();
230 self.active_command_session_id = snapshot.active_session_id;
231 self.last_active_log_line_count = snapshot
232 .active_session_id
233 .and_then(|id| snapshot.sessions.iter().find(|item| item.id == id))
234 .map(|item| item.record.output.display_line_count())
235 .unwrap_or(0);
236 self.command_sessions = snapshot.sessions;
237 }
238
239 pub fn active_command_session(&self) -> Option<&CommandSnapshot> {
240 self.active_command_session_id
241 .and_then(|id| self.command_sessions.iter().find(|item| item.id == id))
242 .or_else(|| self.command_sessions.last())
243 }
244
245 pub fn select_command_session(&mut self, session_id: CommandSessionId) {
246 self.command_runtime.set_active(session_id);
247 self.sync_command_sessions();
248 }
249
250 pub fn request_command_confirmation(&mut self, command: UiCommand) {
251 let label = command.label();
252 let message = command.confirmation_message();
253 self.pending_dialog = Some(UiDialog::Command {
254 command,
255 title: format!("Confirm: {label}"),
256 message,
257 });
258 }
259
260 pub fn request_exit_confirmation(&mut self) {
261 if self.exit_confirmed {
262 return;
263 }
264 self.pending_dialog = Some(UiDialog::Exit {
265 title: "Exit Fission command?".to_string(),
266 message: "Running commands are not stopped automatically. You can cancel and inspect their output before leaving.".to_string(),
267 });
268 }
269
270 pub fn set_scrollback_limit(&mut self, limit: usize) {
271 let limit = limit.max(1);
272 self.scrollback_limit = limit;
273 self.scrollback_limit_input = limit.to_string();
274 self.command_runtime.set_limit(limit);
275 self.sync_command_sessions();
276 }
277}
278
279#[derive(Clone, Debug, PartialEq)]
280pub enum UiDialog {
281 Command {
282 command: UiCommand,
283 title: String,
284 message: String,
285 },
286 Exit {
287 title: String,
288 message: String,
289 },
290}
291
292pub fn log_scroll_widget_id(session_id: CommandSessionId) -> WidgetId {
293 WidgetId::explicit(&format!("{LOG_SCROLL_NODE_ID_PREFIX}_{session_id}"))
294}
295
296pub fn log_visible_rows_for_height(height: f32, compact: bool) -> usize {
297 let density = UiDensity::new(compact);
298 let metrics = density.shell_metrics(height);
299 density.output_log_height(metrics.footer_h).floor().max(1.0) as usize
300}
301
302fn stick_log_scroll_to_bottom(
303 runtime: &mut RuntimeState,
304 env: &Env,
305 session_id: Option<CommandSessionId>,
306 line_count: usize,
307 compact: bool,
308) {
309 let Some(session_id) = session_id else {
310 return;
311 };
312 let visible_rows = log_visible_rows_for_height(env.viewport_size.height, compact);
313 let max_offset = line_count.saturating_sub(visible_rows).max(0) as f32;
314 runtime
315 .scroll
316 .set_offset(log_scroll_widget_id(session_id).into(), max_offset);
317}
318
319fn should_follow_log_output(
320 state: &UiState,
321 runtime: &RuntimeState,
322 env: &Env,
323 next_session_id: Option<CommandSessionId>,
324 next_line_count: usize,
325) -> bool {
326 let Some(next_session_id) = next_session_id else {
327 return false;
328 };
329 if state.active_command_session_id != Some(next_session_id) {
330 return true;
331 }
332 let visible_rows = log_visible_rows_for_height(env.viewport_size.height, state.compact_mode);
333 let old_max = state
334 .last_active_log_line_count
335 .saturating_sub(visible_rows) as f32;
336 let new_max = next_line_count.saturating_sub(visible_rows) as f32;
337 let current = runtime
338 .scroll
339 .get_offset(log_scroll_widget_id(next_session_id).into());
340 current + 2.0 >= old_max || current + 2.0 >= new_max
341}
342
343#[derive(Clone, Debug, PartialEq)]
344pub struct UiDevice {
345 pub id: String,
346 pub name: String,
347 pub target: Target,
348 pub kind: String,
349 pub status: String,
350 pub detail: String,
351 pub available: bool,
352}
353
354impl From<workflow::Device> for UiDevice {
355 fn from(device: workflow::Device) -> Self {
356 Self {
357 id: device.id,
358 name: device.name,
359 target: device.target,
360 kind: device.kind,
361 status: device.status,
362 detail: device.detail,
363 available: device.available,
364 }
365 }
366}
367
368pub fn target_label(target: Target) -> &'static str {
369 match target {
370 Target::Android => "Android",
371 Target::Ios => "iOS",
372 Target::Linux => "Linux",
373 Target::Macos => "macOS",
374 Target::Server => "Server",
375 Target::Site => "Static site",
376 Target::Terminal => "Terminal",
377 Target::Web => "Web",
378 Target::Windows => "Windows",
379 }
380}
381
382pub fn all_targets() -> [Target; 8] {
383 [
384 Target::Android,
385 Target::Ios,
386 Target::Linux,
387 Target::Macos,
388 Target::Server,
389 Target::Site,
390 Target::Web,
391 Target::Windows,
392 ]
393}
394
395fn preferred_target(targets: &[Target]) -> Option<Target> {
396 let host = if cfg!(target_os = "windows") {
397 Target::Windows
398 } else if cfg!(target_os = "macos") {
399 Target::Macos
400 } else {
401 Target::Linux
402 };
403 targets
404 .iter()
405 .copied()
406 .find(|target| *target == host)
407 .or_else(|| {
408 targets
409 .iter()
410 .copied()
411 .find(|target| *target == Target::Web)
412 })
413 .or_else(|| targets.first().copied())
414}