Skip to main content

binocular/runtime/interactive/
bootstrap.rs

1use crate::app::AppEvent::{Input, LogAppend, Preview};
2use crate::app::{App, AppEvent, Mode};
3use crate::config::{LoadedAppConfig, PersistedLayout};
4use crate::infra::channel::{self, MapSender, Sender};
5use crate::infra::terminal::TerminalSessionGuard;
6use crate::output::render_selection_outputs;
7use crate::preview::{self, structured_log, PreviewRequest, PreviewSource};
8use crate::runtime::config::RunConfig;
9use crate::runtime::interactive::input::spawn_input_handler;
10use crate::runtime::interactive::r#loop::run_event_loop;
11use crate::runtime::startup::{self, StartupMode};
12use crate::search::controller::SearchController;
13use crate::search::types::SearchConfig;
14use crossterm::tty::IsTty;
15use ratatui::{backend::CrosstermBackend, Terminal};
16use std::io::{self};
17
18pub fn run_interactive_with_configs(
19    run_config: RunConfig,
20    search_config: SearchConfig,
21    app_config: LoadedAppConfig,
22    persisted_layout: PersistedLayout,
23    log_max_entries: usize,
24) -> anyhow::Result<()> {
25    let prepared_input = startup::prepare_interactive_input_with_run_config(&run_config)?;
26    let mut terminal_session = TerminalSessionGuard::enter()?;
27    let mut terminal = build_terminal()?;
28    let picker = build_picker();
29
30    let (tx_main, rx_main) = channel::unbounded_default::<AppEvent>();
31    let (tx_preview_req, rx_preview_req) = channel::unbounded_default::<PreviewRequest>();
32    let (tx_cmd_noop, _rx_cmd_noop) = channel::unbounded_default();
33
34    let tx_input = MapSender::new(tx_main.clone(), Input);
35    let tx_preview_resp = MapSender::new(tx_main.clone(), |(source, content)| {
36        Preview(source, content)
37    });
38    let tx_log = MapSender::new(tx_main.clone(), |(path, entries)| LogAppend(path, entries));
39
40    let startup_mode = startup::classify_input_mode_with_run_config(&run_config);
41    let mut search_sessions = if matches!(startup_mode, StartupMode::InteractiveDirectDiff) {
42        None
43    } else {
44        Some(SearchController::from_search_config(
45            search_config.clone(),
46            prepared_input.stdin_items,
47            &tx_main,
48            !run_config.log,
49        ))
50    };
51    spawn_input_handler(tx_input);
52    preview::spawn_previewer(
53        rx_preview_req,
54        tx_preview_resp,
55        tx_log.clone(),
56        picker,
57        run_config.preview_command.clone(),
58        run_config.preview_delimiter.clone(),
59        log_max_entries,
60    );
61
62    if let Some(pipe) = prepared_input.log_pipe {
63        let _ = startup::spawn_log_stdin_reader(pipe, tx_log.clone());
64    }
65
66    if !prepared_input.log_files.is_empty() {
67        startup::spawn_log_file_watchers(&prepared_input.log_files, tx_log);
68    }
69
70    let mut app = App::from_configs(run_config, search_config, app_config);
71    initialize_app(&mut app, &persisted_layout, terminal.size()?.into());
72    prime_search_log_and_diff_state(&mut app, search_sessions.as_ref(), &tx_preview_req);
73
74    run_event_loop(
75        &mut app,
76        &mut terminal,
77        &mut terminal_session,
78        &rx_main,
79        &tx_preview_req,
80        &tx_cmd_noop,
81        &mut search_sessions,
82        log_max_entries,
83    )?;
84
85    crate::config::save_layout(&PersistedLayout {
86        panes_swapped: app.ui.layout.panes_swapped,
87        preview_percent: app.ui.layout.preview_percent,
88        search_bar_at_bottom: app.ui.layout.search_bar_at_bottom,
89        preview_hidden: app.ui.layout.preview_hidden,
90    });
91
92    if let Some(search_sessions) = search_sessions.as_mut() {
93        search_sessions.shutdown();
94    }
95    let selected_output = app.take_selected_output();
96    let rendered_output = render_selection_outputs(&selected_output, app.runtime.run.output_format);
97    drop(terminal);
98    drop(terminal_session);
99
100    if let Some(output) = rendered_output {
101        println!("{}", output);
102    }
103
104    Ok(())
105}
106
107fn build_terminal() -> anyhow::Result<Terminal<CrosstermBackend<io::Stderr>>> {
108    let backend = CrosstermBackend::new(io::stderr());
109    Ok(Terminal::new(backend)?)
110}
111
112fn build_picker() -> ratatui_image::picker::Picker {
113    let mut picker = if io::stdout().is_tty() {
114        ratatui_image::picker::Picker::from_query_stdio()
115            .unwrap_or_else(|_| ratatui_image::picker::Picker::halfblocks())
116    } else {
117        ratatui_image::picker::Picker::halfblocks()
118    };
119
120    if std::env::var("TERM_PROGRAM").unwrap_or_default() == "iTerm.app"
121        || std::env::var("LC_TERMINAL").unwrap_or_default() == "iTerm2"
122    {
123        picker.set_protocol_type(ratatui_image::picker::ProtocolType::Iterm2);
124    }
125
126    picker
127}
128
129fn initialize_app(
130    app: &mut App,
131    persisted_layout: &PersistedLayout,
132    terminal_area: ratatui::layout::Rect,
133) {
134    app.ui.layout.panes_swapped = persisted_layout.panes_swapped;
135    app.ui.layout.preview_percent = persisted_layout.preview_percent;
136    app.ui.layout.search_bar_at_bottom = persisted_layout.search_bar_at_bottom;
137    app.ui.layout.preview_hidden = persisted_layout.preview_hidden;
138    app.set_terminal_area(terminal_area);
139    app.refresh_viewports();
140}
141
142fn prime_search_log_and_diff_state(
143    app: &mut App,
144    search_sessions: Option<&SearchController>,
145    tx_preview_req: &channel::DefaultSender<PreviewRequest>,
146) {
147    if app.runtime.run.log {
148        let path = if app.runtime.run.log_files.is_empty() {
149            structured_log::STDIN_STREAM_PATH.to_string()
150        } else {
151            app.runtime.run.log_files[0].display().to_string()
152        };
153        structured_log::initialize_empty_stream(
154            app,
155            path,
156            structured_log::LogFormat::Jsonl,
157        );
158        return;
159    }
160
161    if let Some(diff_paths) = app.runtime.run.diff.as_ref() {
162        let [left, right] = diff_paths;
163        let left = left.display().to_string();
164        let right = right.display().to_string();
165        let source = PreviewSource::Diff {
166            left: left.clone(),
167            right: right.clone(),
168        };
169        app.preview_session.preview.source = Some(source.clone());
170        app.ui.mode = Mode::Preview;
171        app.ui.layout.preview_fullscreen = true;
172        let _ = tx_preview_req.send(PreviewRequest::Diff {
173            source,
174            left,
175            right,
176        });
177        return;
178    }
179
180    if !app.search_session.query.text.is_empty() {
181        if let Some(search_sessions) = search_sessions {
182            search_sessions.send_query(&app.search_session.query.text);
183        }
184    }
185}