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