1use cli_core::eval::{build_create_account_form, build_create_commodity_form, escape_str};
8use cli_core::forms::{
9 LogicalSplitInput, build_transaction_logical_payload, parse_account_options,
10 parse_commodity_options,
11};
12
13use crate::app::App;
14use crate::app::form_options::{
15 inject_all_select_options, inject_select_options, inject_splits_account_options,
16 inject_splits_commodity_options,
17};
18use crate::form::FormKind;
19use crate::modal::Modal;
20use crate::route::{FormOptionsSource, Route, RouteCtx};
21use crate::tabs::config::ConfigCell;
22use crate::tabs::fetch::Fetch;
23use crate::tabs::nms::format_result;
24use crate::tabs::nms_eval::DrainItem;
25use crate::tabs::reports::ReportKind;
26use crate::view::{Tab, ViewId};
27use crate::widgets::SelectOption;
28
29impl App {
30 pub fn fetch_report(&mut self, kind: ReportKind, from: &str, to: &str, chart: &str) {
32 let form = match kind {
33 ReportKind::Balance => "(balance-report)".to_string(),
34 ReportKind::Activity => {
35 format!("(activity-report {} {})", escape_str(from), escape_str(to))
36 }
37 ReportKind::Breakdown => {
38 format!(
39 "(category-breakdown {} {})",
40 escape_str(from),
41 escape_str(to)
42 )
43 }
44 };
45 if self.console_eval.is_none() {
46 self.status = "console not connected".to_string();
47 return;
48 }
49 if let Some(id) = self.dispatch_eval(
50 ViewId::Reports,
51 RouteCtx::Reports {
52 kind,
53 chart: chart.to_string(),
54 },
55 form,
56 ) {
57 self.reports.set_loading(id, kind, chart);
58 }
59 }
60
61 pub fn submit_console_form(&mut self, form: String) {
62 self.console.push_scrollback(format!("> {form}"));
63 self.console.reset_scroll();
64 if self.console_eval.is_none() {
65 self.console.push_scrollback("console not connected");
66 return;
67 }
68 if self
69 .dispatch_eval(ViewId::Console, RouteCtx::None, form)
70 .is_none()
71 {
72 self.console.push_scrollback("eval worker stopped");
73 }
74 }
75
76 pub fn interrupt_console(&self) {
77 if let Some(eval) = &self.console_eval {
78 eval.interrupt();
79 }
80 }
81
82 pub fn drain_eval(&mut self) {
83 let items = {
84 let Some(eval) = &mut self.console_eval else {
85 return;
86 };
87 eval.drain()
88 };
89 for item in items {
90 match item {
91 DrainItem::Routed { id, wire } => self.route_reply(id, &wire),
92 DrainItem::Unroutable(wire) => {
93 for line in format_result(&wire) {
94 self.console.push_scrollback(line);
95 }
96 }
97 DrainItem::Notice(text) => {
98 self.console.push_scrollback(text);
99 self.fail_inflight_fetches("eval worker stopped");
100 }
101 }
102 }
103 }
104
105 pub fn drain_console(&mut self) {
106 self.drain_eval();
107 }
108
109 pub fn refresh_tab(&mut self, tab: Tab) {
110 let in_flight = match tab {
111 Tab::Accounts => matches!(self.accounts.state, Fetch::Loading { .. }),
112 Tab::Transactions => matches!(self.transactions.state, Fetch::Loading { .. }),
113 Tab::Commodities => matches!(self.commodities.state, Fetch::Loading { .. }),
114 Tab::Reports => matches!(self.reports.state, Fetch::Loading { .. }),
115 Tab::Config => self.config.any_loading(),
116 _ => false,
117 };
118 if in_flight {
119 return;
120 }
121 match tab {
122 Tab::Accounts => self.accounts.reset(),
123 Tab::Transactions => self.transactions.reset(),
124 Tab::Commodities => self.commodities.reset(),
125 Tab::Reports => self.reports.reset(),
126 Tab::Config => self.config.reset(),
127 _ => {}
128 }
129 self.ensure_tab_loaded(tab);
130 }
131
132 pub fn fetch_config(&mut self) {
133 if self.console_eval.is_none() {
134 return;
135 }
136 let keys: Vec<String> = self
137 .config
138 .entries
139 .iter()
140 .filter(|(_, c)| matches!(c, ConfigCell::Unset))
141 .map(|(k, _)| k.clone())
142 .collect();
143 for key in keys {
144 let form = format!("(get-config {})", escape_str(&key));
145 if let Some(id) =
146 self.dispatch_eval(ViewId::Config, RouteCtx::Config { key: key.clone() }, form)
147 {
148 self.config.set_loading(&key, id);
149 }
150 }
151 }
152
153 pub fn submit_config_set(&mut self, key: &str, value: &str) -> bool {
154 let form = crate::tabs::config::build_set_config_form(key, value);
155 if self.console_eval.is_none() {
156 self.status = "console not connected".to_string();
157 return false;
158 }
159 match self.dispatch_eval(ViewId::Console, RouteCtx::None, form) {
160 Some(_) => {
161 self.refetch_config_key(key);
162 true
163 }
164 None => {
165 self.status = "eval worker stopped".to_string();
166 false
167 }
168 }
169 }
170
171 pub fn submit_commodity_create(&mut self, symbol: &str, name: &str) -> bool {
173 let form = build_create_commodity_form(symbol, name);
174 if self.console_eval.is_none() {
175 self.status = "console not connected".to_string();
176 return false;
177 }
178 match self.dispatch_eval(
179 ViewId::Commodities,
180 RouteCtx::Mutation {
181 refresh: ViewId::Commodities,
182 },
183 form,
184 ) {
185 Some(_) => true,
186 None => {
187 self.status = "eval worker stopped".to_string();
188 false
189 }
190 }
191 }
192
193 pub fn submit_account_create(&mut self, name: &str, parent: Option<&str>) -> bool {
195 let form = build_create_account_form(name, parent);
196 if self.console_eval.is_none() {
197 self.status = "console not connected".to_string();
198 return false;
199 }
200 match self.dispatch_eval(
201 ViewId::Accounts,
202 RouteCtx::Mutation {
203 refresh: ViewId::Accounts,
204 },
205 form,
206 ) {
207 Some(_) => true,
208 None => {
209 self.status = "eval worker stopped".to_string();
210 false
211 }
212 }
213 }
214
215 pub fn submit_transaction_create(
218 &mut self,
219 splits: &[LogicalSplitInput],
220 note: &str,
221 date: &str,
222 ) -> bool {
223 let form = match build_transaction_logical_payload(splits, note, date) {
224 Ok(f) => f,
225 Err(e) => {
226 self.status = format!("transaction payload error: {e}");
227 return false;
228 }
229 };
230 if self.console_eval.is_none() {
231 self.status = "console not connected".to_string();
232 return false;
233 }
234 match self.dispatch_eval(
235 ViewId::Transactions,
236 RouteCtx::Mutation {
237 refresh: ViewId::Transactions,
238 },
239 form,
240 ) {
241 Some(_) => true,
242 None => {
243 self.status = "eval worker stopped".to_string();
244 false
245 }
246 }
247 }
248
249 pub(super) fn ensure_tab_loaded(&mut self, tab: Tab) {
250 match tab {
251 Tab::Accounts | Tab::Transactions | Tab::Commodities => {
252 self.ensure_list_tab_loaded(tab);
253 }
254 Tab::Config if self.config.is_idle() => {
255 self.fetch_config();
256 }
257 _ => {}
258 }
259 }
260
261 fn ensure_list_tab_loaded(&mut self, tab: Tab) {
262 let (form, target) = match tab {
263 Tab::Accounts if matches!(self.accounts.state, Fetch::Idle) => {
264 (self.accounts.request_form.clone(), ViewId::Accounts)
265 }
266 Tab::Transactions if matches!(self.transactions.state, Fetch::Idle) => {
267 (self.transactions.request_form.clone(), ViewId::Transactions)
268 }
269 Tab::Commodities if matches!(self.commodities.state, Fetch::Idle) => {
270 (self.commodities.request_form.clone(), ViewId::Commodities)
271 }
272 _ => return,
273 };
274 if let Some(id) = self.dispatch_eval(target, RouteCtx::None, form) {
275 match tab {
276 Tab::Accounts => self.accounts.set_loading_id(id),
277 Tab::Transactions => self.transactions.set_loading_id(id),
278 Tab::Commodities => self.commodities.set_loading_id(id),
279 _ => {}
280 }
281 }
282 }
283
284 fn refetch_config_key(&mut self, key: &str) {
285 let form = format!("(get-config {})", escape_str(key));
286 if let Some(id) = self.dispatch_eval(
287 ViewId::Config,
288 RouteCtx::Config {
289 key: key.to_string(),
290 },
291 form,
292 ) {
293 self.config.set_loading(key, id);
294 }
295 }
296
297 pub(super) fn dispatch_eval(
301 &mut self,
302 target: ViewId,
303 ctx: RouteCtx,
304 form: String,
305 ) -> Option<i64> {
306 let eval = self.console_eval.as_mut()?;
307 let id = eval.submit(form)? as i64;
308 self.pending_routes.insert(id, Route { target, ctx });
309 Some(id)
310 }
311
312 pub(super) fn deliver_reply(&mut self, route: Route, wire: &str) {
316 match route {
317 Route {
318 ctx: RouteCtx::Mutation { refresh },
319 ..
320 } => self.deliver_mutation_reply(wire, refresh),
321 Route {
322 ctx: RouteCtx::FormOptions { seq, source },
323 ..
324 } => self.deliver_form_options_reply(wire, seq, source),
325 Route {
326 ctx: RouteCtx::TransactionEdit,
327 ..
328 } => self.deliver_transaction_edit_reply(wire),
329 Route {
330 ctx:
331 RouteCtx::ConvertQuery {
332 amount_str,
333 from_label,
334 to_label,
335 },
336 ..
337 } => self.deliver_convert_reply(wire, &amount_str, &from_label, &to_label),
338 Route {
339 target: ViewId::Reports,
340 ctx: RouteCtx::Reports { kind, chart },
341 } => self.reports.on_reply(wire, kind, &chart),
342 Route {
343 target: ViewId::Config,
344 ctx: RouteCtx::Config { key },
345 } => self.config.on_reply(&key, wire),
346 Route { target, .. } => match target {
347 ViewId::Console => {
348 for line in format_result(wire) {
349 self.console.push_scrollback(line);
350 }
351 }
352 ViewId::Accounts => self.accounts.on_reply(wire),
353 ViewId::Transactions => self.transactions.on_reply(wire),
354 ViewId::Commodities => self.commodities.on_reply(wire),
355 ViewId::Reports | ViewId::Config => {}
356 },
357 }
358 }
359
360 fn deliver_mutation_reply(&mut self, wire: &str, refresh: ViewId) {
361 match cli_core::render::parse_wire(wire) {
362 Ok(_) => self.refresh_tab(refresh),
363 Err(e) => self.status = format!("mutation failed: {e}"),
364 }
365 }
366
367 fn deliver_form_options_reply(&mut self, wire: &str, seq: u64, source: FormOptionsSource) {
368 if seq != self.form_options_seq {
369 return;
370 }
371 match source {
372 FormOptionsSource::Accounts => self.deliver_account_options(wire),
373 FormOptionsSource::Commodities => self.deliver_commodity_options(wire),
374 }
375 }
376
377 fn deliver_account_options(&mut self, wire: &str) {
379 let pairs = parse_account_options(wire);
380 if pairs.is_empty() {
381 return;
382 }
383 let mut options: Vec<SelectOption> = vec![SelectOption {
384 id: String::new(),
385 label: "(none)".to_string(),
386 }];
387 options.extend(
388 pairs
389 .into_iter()
390 .map(|(id, label)| SelectOption { id, label }),
391 );
392 if let Some(Modal::Form(form)) = self.overlays.top_mut() {
394 match form.kind {
395 FormKind::AccountCreate => {
396 inject_select_options(&mut form.fields, &options, false);
397 }
398 FormKind::TransactionCreate | FormKind::TransactionEdit => {
399 inject_splits_account_options(&mut form.fields, options);
400 }
401 _ => {}
402 }
403 }
404 }
405
406 fn deliver_commodity_options(&mut self, wire: &str) {
407 let pairs = parse_commodity_options(wire);
408 if pairs.is_empty() {
409 return;
410 }
411 let options: Vec<SelectOption> = pairs
412 .into_iter()
413 .map(|(id, label)| SelectOption { id, label })
414 .collect();
415 if let Some(Modal::Form(form)) = self.overlays.top_mut() {
416 match form.kind {
417 FormKind::TransactionCreate | FormKind::TransactionEdit => {
418 inject_splits_commodity_options(&mut form.fields, options);
419 }
420 FormKind::CommodityConvert => {
421 inject_all_select_options(&mut form.fields, &options);
422 }
423 _ => {}
424 }
425 }
426 }
427
428 pub(super) fn route_reply(&mut self, id: i64, wire: &str) {
431 match self.pending_routes.remove(&id) {
432 Some(route) => self.deliver_reply(route, wire),
433 None => {
434 self.status = format!("[warn] orphan reply id={id}");
435 for line in format_result(wire) {
436 self.console.push_scrollback(line);
437 }
438 }
439 }
440 }
441
442 fn fail_inflight_fetches(&mut self, reason: &str) {
443 self.pending_routes.clear();
444 for tab in [
445 &mut self.accounts,
446 &mut self.transactions,
447 &mut self.commodities,
448 ] {
449 if matches!(tab.state, Fetch::Loading { .. }) {
450 tab.state = Fetch::Error(reason.to_string());
451 }
452 }
453 if matches!(self.reports.state, Fetch::Loading { .. }) {
454 self.reports.state = Fetch::Error(reason.to_string());
455 }
456 for (_, cell) in &mut self.config.entries {
457 if matches!(cell, ConfigCell::Loading { .. }) {
458 *cell = ConfigCell::Error(reason.to_string());
459 }
460 }
461 }
462}