Skip to main content

fission_command_ui/
state.rs

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}