binocular/runtime/interactive/
bootstrap.rs1use 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}