Line data Source code
1 : /*!
2 : * \file esys/repo/tui/run_compact_sync_tui.cpp
3 : * \brief
4 : *
5 : * \cond
6 : * __legal_b__
7 : *
8 : * Copyright (c) 2026 Michel Gillet
9 : * Distributed under the MIT License.
10 : * (See accompanying file LICENSE.txt or
11 : * copy at https://opensource.org/licenses/MIT)
12 : *
13 : * __legal_e__
14 : * \endcond
15 : *
16 : */
17 :
18 : #include "esys/repo/esysrepo_prec.h"
19 : #include "esys/repo/tui/run_compact_sync_tui.h"
20 : #include "esys/repo/resultcode.h"
21 :
22 : #ifndef ESYSREPO_HAVE_TUI
23 :
24 : namespace esys::repo::tui
25 : {
26 :
27 : Result run_compact_sync_tui(SyncTuiWork /*work*/, const std::vector<std::string> & /*repo_names*/,
28 : std::size_t /*worker_count*/, std::shared_ptr<log::Logger_if> /*logger*/,
29 : const SyncTuiOptions & /*options*/)
30 : {
31 : return ESYSREPO_RESULT(ResultCode::NOT_IMPLEMENTED);
32 : }
33 :
34 : } // namespace esys::repo::tui
35 :
36 : #else
37 :
38 : #include "esys/repo/progress/progresssession.h"
39 : #include "esys/repo/tui/appmodel.h"
40 : #include "esys/repo/tui/capturelogger.h"
41 : #include "esys/repo/tui/eventqueue.h"
42 : #include "esys/repo/tui/is_tty.h"
43 : #include "esys/repo/tui/logbuffer.h"
44 : #include "esys/repo/tui/queueobserver.h"
45 :
46 : #include <esys/log/loggerbase.h>
47 :
48 : #include <ftxui/component/component.hpp>
49 : #include <ftxui/component/screen_interactive.hpp>
50 : #include <ftxui/dom/elements.hpp>
51 :
52 : #include <algorithm>
53 : #include <atomic>
54 : #include <chrono>
55 : #include <cmath>
56 : #include <cstdlib>
57 : #include <fstream>
58 : #include <future>
59 : #include <iostream>
60 : #include <sstream>
61 : #include <string>
62 : #include <thread>
63 : #include <type_traits>
64 : #include <utility>
65 : #include <variant>
66 : #include <vector>
67 :
68 : namespace esys::repo::tui
69 : {
70 :
71 : namespace
72 : {
73 :
74 : constexpr int k_worker_id_width = 4;
75 : constexpr int k_phase_width = 10;
76 : constexpr int k_status_width = 8;
77 : constexpr int k_min_repo_label_width = 12;
78 : constexpr int k_min_gauge_width = 12;
79 : constexpr int k_gauge_label_width = 4; // " Rx" / " Idx" / " Chk"
80 : constexpr int k_detail_panel_floor = 28;
81 :
82 0 : float gauge_ratio(int current, int total)
83 : {
84 0 : if (total <= 0) return 0.f;
85 0 : if (current < 0) return 0.f;
86 0 : if (current >= total) return 1.f;
87 0 : return static_cast<float>(current) / static_cast<float>(total);
88 : }
89 :
90 : //! Done repos always render full gauges (including never-updated fast syncs).
91 0 : float gauge_ratio_for(const RepoState &repo, int current, int total)
92 : {
93 0 : if (repo.get_status() == RepoStatus::DONE) return 1.f;
94 0 : return gauge_ratio(current, total);
95 : }
96 :
97 0 : std::string repo_display_name(const RepoState &repo)
98 : {
99 0 : std::string name = repo.get_name().empty() ? repo.get_path() : repo.get_name();
100 0 : if (name.empty()) name = "repo";
101 0 : return name;
102 0 : }
103 :
104 0 : std::string fit_width(std::string s, int width)
105 : {
106 0 : if (width <= 0) return {};
107 0 : if (static_cast<int>(s.size()) == width) return s;
108 0 : if (static_cast<int>(s.size()) < width)
109 0 : return s + std::string(static_cast<std::size_t>(width - static_cast<int>(s.size())), ' ');
110 0 : if (width <= 3) return s.substr(0, static_cast<std::size_t>(width));
111 0 : return s.substr(0, static_cast<std::size_t>(width - 3)) + "...";
112 : }
113 :
114 : //! Stable repo-label column: max(name/path) across the whole queue, never below a floor.
115 0 : int repo_label_column_width(const AppState &state)
116 : {
117 0 : int width = k_min_repo_label_width;
118 0 : width = (std::max)(width, 6); // "<idle>"
119 0 : for (const auto &repo : state.get_repos())
120 0 : width = (std::max)(width, static_cast<int>(repo_display_name(repo).size()));
121 0 : return width;
122 : }
123 :
124 : //! Detail panel: wide enough for the longest repo title and usable gauges.
125 0 : int detail_panel_min_width(const AppState &state)
126 : {
127 : // Window title chrome (~4) + longest name + room for vertical gauges.
128 0 : const int from_title = repo_label_column_width(state) + 4;
129 0 : const int from_gauges = k_min_gauge_width + 6;
130 0 : return (std::max)({k_detail_panel_floor, from_title, from_gauges});
131 : }
132 :
133 0 : ftxui::Element gauge_slot(float ratio)
134 : {
135 0 : using namespace ftxui;
136 :
137 : // Clamp for display; NaN → 0.
138 0 : float r = ratio;
139 0 : if (!(r > 0.f)) r = 0.f;
140 0 : if (!(r < 1.f)) r = 1.f;
141 :
142 0 : int pct = static_cast<int>(std::lround(static_cast<double>(r) * 100.0));
143 0 : if (pct < 0) pct = 0;
144 : if (pct > 100) pct = 100;
145 :
146 0 : char label[8];
147 0 : std::snprintf(label, sizeof(label), "%3d%%", pct);
148 :
149 : // Overlay centered % on the bar. inverted gives a solid reverse-video chip
150 : // so digits stay readable whether the fill behind is empty or solid.
151 0 : return dbox({
152 0 : gauge(r) | color(Color::Cyan),
153 0 : hcenter(text(label) | bold | inverted),
154 : })
155 0 : | size(WIDTH, GREATER_THAN, k_min_gauge_width) | flex;
156 : }
157 :
158 0 : ftxui::Element framed_window(ftxui::Element title, ftxui::Element content)
159 : {
160 0 : using namespace ftxui;
161 : // LIGHT box glyphs are more widely available than ROUNDED on classic cmd fonts.
162 0 : return window(std::move(title), std::move(content), BorderStyle::LIGHT);
163 : }
164 :
165 : //! Like window(), but the title spans the full top edge (FTXUI window() only
166 : //! sizes the title to its min width, so filler cannot flush-right).
167 0 : ftxui::Element framed_main_window(ftxui::Element title, ftxui::Element content)
168 : {
169 0 : using namespace ftxui;
170 0 : return vbox({
171 0 : std::move(title),
172 : separator(),
173 0 : std::move(content) | flex,
174 : })
175 0 : | borderStyled(BorderStyle::LIGHT);
176 : }
177 :
178 0 : const char *layout_mode_title(LayoutMode mode)
179 : {
180 0 : switch (mode)
181 : {
182 : case LayoutMode::AUTO: return "Auto";
183 0 : case LayoutMode::COMPACT: return "Compact";
184 0 : case LayoutMode::BALANCED: return "Balanced";
185 0 : case LayoutMode::DETAILED: return "Detailed";
186 0 : case LayoutMode::QUEUE_FIRST: return "Queue First";
187 : }
188 : return "Auto";
189 : }
190 :
191 : //! Left: product name. Right: dashboard name flush to the right edge (Tab cycles).
192 0 : ftxui::Element main_window_title(LayoutMode requested, LayoutMode effective)
193 : {
194 0 : using namespace ftxui;
195 :
196 0 : std::string right = layout_mode_title(requested);
197 0 : if (requested == LayoutMode::AUTO)
198 : {
199 0 : right += " → ";
200 0 : right += layout_mode_title(effective);
201 : }
202 :
203 0 : return hbox({
204 : text(" ESysRepo Sync"),
205 : filler(),
206 : text(right),
207 0 : });
208 0 : }
209 :
210 0 : ftxui::Element render_demo_banner(const std::string &caption)
211 : {
212 0 : using namespace ftxui;
213 0 : return hbox({
214 : text(" "),
215 0 : text(caption) | bold,
216 : text(" "),
217 : })
218 0 : | borderStyled(BorderStyle::LIGHT) | inverted;
219 : }
220 :
221 0 : ftxui::Element with_demo_banner(ftxui::Element frame, const std::string &caption)
222 : {
223 0 : using namespace ftxui;
224 : // Top-centered overlay (same role as the ffmpeg demo caption).
225 0 : return dbox({
226 0 : std::move(frame),
227 0 : vbox({
228 0 : text("") | size(HEIGHT, EQUAL, 2),
229 0 : hbox({filler(), render_demo_banner(caption), filler()}),
230 : filler(),
231 : }),
232 0 : });
233 : }
234 :
235 0 : ftxui::Decorator status_style(RepoStatus status)
236 : {
237 0 : using namespace ftxui;
238 0 : switch (status)
239 : {
240 0 : case RepoStatus::DONE: return color(Color::Green);
241 0 : case RepoStatus::FAILED: return color(Color::Red);
242 0 : default: return nothing;
243 : }
244 : }
245 :
246 0 : ftxui::Decorator phase_style(const std::string &phase)
247 : {
248 0 : using namespace ftxui;
249 0 : if (phase == "Done") return color(Color::Green);
250 0 : if (phase == "Failed") return color(Color::Red);
251 0 : return nothing;
252 : }
253 :
254 0 : ftxui::Element render_summary_line(const AppState &state)
255 : {
256 0 : using namespace ftxui;
257 :
258 0 : Decorator reason_style = nothing;
259 0 : switch (state.get_queue_reason())
260 : {
261 0 : case QueueReason::PAUSED:
262 0 : case QueueReason::BACKPRESSURE: reason_style = color(Color::Yellow); break;
263 0 : case QueueReason::EMPTY: reason_style = color(Color::Green); break;
264 : default: break;
265 : }
266 :
267 0 : return hbox({
268 0 : text("Done: ") | color(Color::Green),
269 0 : text(std::to_string(state.get_done_count())) | color(Color::Green),
270 0 : text(" Running: " + std::to_string(state.get_running_count())),
271 0 : text(" Pending: " + std::to_string(state.get_pending_count())),
272 0 : text(" Failed: ") | color(Color::Red),
273 0 : text(std::to_string(state.get_failed_count())) | color(Color::Red),
274 : text(" Queue: "),
275 0 : text(queue_reason_to_string(state.get_queue_reason())) | reason_style,
276 0 : text(" Filter: " + std::string(queue_filter_to_string(state.get_queue_filter())) + " (f)"),
277 0 : });
278 0 : }
279 :
280 0 : ftxui::Element render_log_pane(const AppState &state)
281 : {
282 0 : using namespace ftxui;
283 :
284 0 : const int height = (std::max)(2, state.get_log_pane_height());
285 0 : const auto &lines = state.get_log_lines();
286 0 : const int n = static_cast<int>(lines.size());
287 0 : int start = state.get_log_scroll_offset();
288 0 : if (start < 0) start = 0;
289 0 : if (start > (std::max)(0, n - height)) start = (std::max)(0, n - height);
290 :
291 0 : Elements rows;
292 0 : rows.reserve(static_cast<std::size_t>(height));
293 0 : for (int i = 0; i < height; ++i)
294 : {
295 0 : const int idx = start + i;
296 0 : if (idx >= 0 && idx < n)
297 0 : rows.push_back(text(lines[static_cast<std::size_t>(idx)]));
298 : else
299 0 : rows.push_back(text(""));
300 : }
301 :
302 0 : const std::string title = state.get_log_follow_tail() ? "Output (PgUp/PgDn) · follow"
303 0 : : "Output (PgUp/PgDn) · scrolled";
304 0 : return window(text(title), vbox(std::move(rows)) | size(HEIGHT, EQUAL, height));
305 0 : }
306 :
307 0 : ftxui::Element render_summary_and_output(const AppState &state)
308 : {
309 0 : using namespace ftxui;
310 0 : return vbox({
311 : render_summary_line(state),
312 : render_log_pane(state),
313 0 : });
314 : }
315 :
316 0 : bool repo_matches_filter(const RepoState &repo, QueueFilter filter)
317 : {
318 0 : switch (filter)
319 : {
320 : case QueueFilter::ALL: return true;
321 0 : case QueueFilter::PENDING: return repo.get_status() == RepoStatus::PENDING || repo.get_status() == RepoStatus::SKIPPED;
322 0 : case QueueFilter::RUNNING: return repo.get_status() == RepoStatus::RUNNING;
323 0 : case QueueFilter::FAILED: return repo.get_status() == RepoStatus::FAILED;
324 0 : case QueueFilter::DONE: return repo.get_status() == RepoStatus::DONE;
325 : }
326 : return true;
327 : }
328 :
329 0 : ftxui::Element render_worker_rows(const AppState &state)
330 : {
331 0 : using namespace ftxui;
332 :
333 0 : const int name_width = repo_label_column_width(state);
334 0 : Elements worker_rows;
335 0 : worker_rows.reserve(state.get_workers().size());
336 :
337 0 : for (const auto &worker : state.get_workers())
338 : {
339 0 : std::string repo_name = "<idle>";
340 0 : std::string phase = "Idle";
341 0 : progress::TransferCounters counters;
342 0 : const RepoState *assigned_repo = nullptr;
343 :
344 0 : if (worker.get_repo_index().has_value())
345 : {
346 0 : const std::size_t idx = *worker.get_repo_index();
347 0 : if (idx < state.get_repos().size())
348 : {
349 0 : const auto &repo = state.get_repos()[idx];
350 0 : assigned_repo = &repo;
351 0 : repo_name = repo_display_name(repo);
352 0 : phase = repo.get_phase_text().empty() ? "Pending" : repo.get_phase_text();
353 0 : counters = repo.get_progress();
354 : }
355 : }
356 :
357 0 : if (worker.get_errored() && worker.get_busy()) phase = "Failed";
358 :
359 0 : const float rx = assigned_repo != nullptr
360 0 : ? gauge_ratio_for(*assigned_repo, counters.get_received_objects(), counters.get_total_objects())
361 0 : : gauge_ratio(counters.get_received_objects(), counters.get_total_objects());
362 0 : const float idx = assigned_repo != nullptr
363 0 : ? gauge_ratio_for(*assigned_repo, counters.get_indexed_deltas(), counters.get_total_deltas())
364 0 : : gauge_ratio(counters.get_indexed_deltas(), counters.get_total_deltas());
365 0 : const float chk =
366 : assigned_repo != nullptr
367 0 : ? gauge_ratio_for(*assigned_repo, counters.get_checkout_steps(), counters.get_total_checkout_steps())
368 0 : : gauge_ratio(counters.get_checkout_steps(), counters.get_total_checkout_steps());
369 :
370 0 : std::ostringstream wlabel;
371 0 : wlabel << "W" << worker.get_id();
372 :
373 0 : worker_rows.push_back(hbox({
374 0 : text(fit_width(wlabel.str(), k_worker_id_width)) | size(WIDTH, EQUAL, k_worker_id_width),
375 : separator(),
376 0 : text(fit_width(repo_name, name_width)) | size(WIDTH, EQUAL, name_width),
377 : text(" "), // keep name and status from running together
378 0 : text(fit_width(phase, k_phase_width)) | size(WIDTH, EQUAL, k_phase_width) | phase_style(phase),
379 0 : text(fit_width(" Rx", k_gauge_label_width)) | size(WIDTH, EQUAL, k_gauge_label_width),
380 : gauge_slot(rx),
381 0 : text(fit_width(" Idx", k_gauge_label_width)) | size(WIDTH, EQUAL, k_gauge_label_width),
382 : gauge_slot(idx),
383 0 : text(fit_width(" Chk", k_gauge_label_width)) | size(WIDTH, EQUAL, k_gauge_label_width),
384 : gauge_slot(chk),
385 : }));
386 0 : }
387 0 : return vbox(std::move(worker_rows));
388 0 : }
389 :
390 0 : ftxui::Element render_compact_dashboard(const AppState &state, LayoutMode effective_layout)
391 : {
392 0 : using namespace ftxui;
393 :
394 0 : std::string detail = "Detail: <none>";
395 0 : if (!state.get_repos().empty())
396 : {
397 0 : const auto selected = static_cast<std::size_t>(state.get_selected_repo()) % state.get_repos().size();
398 0 : const auto &repo = state.get_repos()[selected];
399 0 : std::ostringstream oss;
400 0 : oss << "Detail: " << repo_display_name(repo) << " - ";
401 0 : if (!repo.get_error_text().empty())
402 0 : oss << repo.get_error_text();
403 0 : else if (!repo.get_detail_text().empty())
404 0 : oss << repo.get_detail_text();
405 : else
406 0 : oss << repo.get_phase_text();
407 0 : detail = oss.str();
408 0 : }
409 :
410 0 : return framed_main_window(main_window_title(state.get_layout_mode(), effective_layout), vbox({
411 : render_worker_rows(state),
412 : separator(),
413 : render_summary_and_output(state),
414 : text(detail),
415 0 : }));
416 0 : }
417 :
418 0 : const char *status_to_string(RepoStatus status)
419 : {
420 0 : switch (status)
421 : {
422 : case RepoStatus::PENDING: return "Pending";
423 0 : case RepoStatus::RUNNING: return "Running";
424 0 : case RepoStatus::DONE: return "Done";
425 0 : case RepoStatus::FAILED: return "Failed";
426 0 : case RepoStatus::SKIPPED: return "Skipped";
427 : }
428 0 : return "";
429 : }
430 :
431 0 : ftxui::Element render_repo_detail_panel(const AppState &state)
432 : {
433 0 : using namespace ftxui;
434 :
435 0 : const int min_width = detail_panel_min_width(state);
436 :
437 0 : if (state.get_repos().empty())
438 0 : return framed_window(text("Detail"), text("<none>")) | size(WIDTH, GREATER_THAN, min_width);
439 :
440 0 : const std::size_t selected = static_cast<std::size_t>(state.get_selected_repo()) % state.get_repos().size();
441 0 : const auto &repo = state.get_repos()[selected];
442 :
443 0 : const auto &c = repo.get_progress();
444 0 : const float rx = gauge_ratio_for(repo, c.get_received_objects(), c.get_total_objects());
445 0 : const float idx = gauge_ratio_for(repo, c.get_indexed_deltas(), c.get_total_deltas());
446 0 : const float chk = gauge_ratio_for(repo, c.get_checkout_steps(), c.get_total_checkout_steps());
447 :
448 0 : std::ostringstream detail;
449 0 : if (!repo.get_error_text().empty()) detail << repo.get_error_text();
450 0 : else if (!repo.get_detail_text().empty()) detail << repo.get_detail_text();
451 0 : else detail << repo.get_phase_text();
452 :
453 : // Title uses the same max-name width so long paths do not jitter the panel.
454 0 : const std::string title = fit_width(repo_display_name(repo), repo_label_column_width(state));
455 0 : std::string phase = repo.get_phase_text();
456 0 : if (phase.empty()) phase = status_to_string(repo.get_status());
457 :
458 0 : return framed_window(text(title), vbox({
459 0 : text(phase) | phase_style(phase) | status_style(repo.get_status()),
460 : separator(),
461 : text("Rx"), gauge_slot(rx),
462 : text("Idx"), gauge_slot(idx),
463 : text("Chk"), gauge_slot(chk),
464 : separator(),
465 0 : text(detail.str())
466 0 : | (repo.get_status() == RepoStatus::FAILED ? color(Color::Red) : nothing),
467 : }))
468 0 : | size(WIDTH, GREATER_THAN, min_width);
469 0 : }
470 :
471 0 : ftxui::Element render_worker_strip_panel(const AppState &state)
472 : {
473 0 : using namespace ftxui;
474 :
475 : // Fixed cell width from longest phase label so the strip does not jitter.
476 0 : constexpr int k_strip_min = 12;
477 0 : int cell_width = k_strip_min;
478 0 : for (const auto &worker : state.get_workers())
479 : {
480 0 : std::ostringstream line;
481 0 : line << "W" << worker.get_id() << ":";
482 0 : if (worker.get_repo_index().has_value())
483 : {
484 0 : const std::size_t idx = *worker.get_repo_index();
485 0 : if (idx < state.get_repos().size())
486 : {
487 0 : const auto &repo = state.get_repos()[idx];
488 0 : line << ((worker.get_errored() && worker.get_busy())
489 0 : ? "Failed"
490 0 : : (repo.get_phase_text().empty() ? "Pending" : repo.get_phase_text()));
491 : }
492 : else
493 0 : line << "Pending";
494 : }
495 : else
496 0 : line << "Idle";
497 0 : cell_width = (std::max)(cell_width, static_cast<int>(line.str().size()) + 2);
498 0 : }
499 :
500 0 : Elements cells;
501 0 : cells.reserve(state.get_workers().size());
502 0 : for (const auto &worker : state.get_workers())
503 : {
504 0 : std::ostringstream line;
505 0 : line << "W" << worker.get_id() << ":";
506 0 : if (worker.get_repo_index().has_value())
507 : {
508 0 : const std::size_t idx = *worker.get_repo_index();
509 0 : if (idx < state.get_repos().size())
510 : {
511 0 : const auto &repo = state.get_repos()[idx];
512 0 : line << ((worker.get_errored() && worker.get_busy())
513 0 : ? "Failed"
514 0 : : (repo.get_phase_text().empty() ? "Pending" : repo.get_phase_text()));
515 : }
516 : else
517 0 : line << "Pending";
518 : }
519 : else
520 0 : line << "Idle";
521 :
522 0 : const std::string fitted = fit_width(line.str(), cell_width);
523 0 : Decorator style = nothing;
524 0 : if (fitted.find("Failed") != std::string::npos) style = color(Color::Red);
525 0 : else if (fitted.find("Done") != std::string::npos) style = color(Color::Green);
526 :
527 0 : cells.push_back(framed_window(text(fitted) | style, text("")) | size(WIDTH, EQUAL, cell_width + 2));
528 0 : }
529 :
530 0 : return vbox(std::move(cells));
531 0 : }
532 :
533 : //! Repo queue: name + status (gauges live in the detail panel on the right).
534 0 : ftxui::Element render_queue_table_panel(const AppState &state)
535 : {
536 0 : using namespace ftxui;
537 :
538 0 : const int name_width = repo_label_column_width(state);
539 0 : Elements rows;
540 0 : rows.reserve(state.get_repos().size() + 1);
541 :
542 0 : rows.push_back(hbox({
543 0 : text(fit_width("Repo", name_width + 2)) | size(WIDTH, EQUAL, name_width + 2),
544 : text(" "),
545 0 : text(fit_width("Status", k_status_width)) | size(WIDTH, EQUAL, k_status_width),
546 : }));
547 :
548 0 : for (std::size_t i = 0; i < state.get_repos().size(); ++i)
549 : {
550 0 : const auto &repo = state.get_repos()[i];
551 0 : if (!repo_matches_filter(repo, state.get_queue_filter())) continue;
552 :
553 0 : const bool selected = (static_cast<int>(i) == state.get_selected_repo());
554 :
555 0 : std::ostringstream label;
556 0 : label << (selected ? ">" : " ") << " " << repo_display_name(repo);
557 :
558 0 : rows.push_back(hbox({
559 0 : text(fit_width(label.str(), name_width + 2)) | size(WIDTH, EQUAL, name_width + 2),
560 : text(" "), // keep name and status from running together
561 0 : text(fit_width(status_to_string(repo.get_status()), k_status_width)) | size(WIDTH, EQUAL, k_status_width)
562 0 : | status_style(repo.get_status()),
563 : }));
564 0 : }
565 :
566 0 : if (rows.size() == 1)
567 0 : rows.push_back(text("(no repos match filter)"));
568 :
569 0 : return framed_window(text("Repos"), vbox(std::move(rows))) | flex;
570 0 : }
571 :
572 0 : LayoutMode resolve_effective_layout(LayoutMode requested, int cols, int rows)
573 : {
574 0 : if (requested != LayoutMode::AUTO) return requested;
575 :
576 0 : const bool small_width = cols > 0 && cols < 100;
577 0 : const bool small_height = rows > 0 && rows < 24;
578 0 : if (small_width || small_height) return LayoutMode::COMPACT;
579 0 : if (cols > 0 && cols < 140) return LayoutMode::BALANCED;
580 : return LayoutMode::DETAILED;
581 : }
582 :
583 : //! Pin AUTO layout until terminal size moves by a clear margin (avoids flicker).
584 0 : LayoutMode resolve_layout_stable(LayoutMode requested, int cols, int rows, LayoutMode &pinned,
585 : int &pinned_cols, int &pinned_rows)
586 : {
587 0 : if (requested != LayoutMode::AUTO)
588 : {
589 0 : pinned = requested;
590 0 : if (cols > 0) pinned_cols = cols;
591 0 : if (rows > 0) pinned_rows = rows;
592 0 : return requested;
593 : }
594 :
595 : // Never pin or switch on unknown geometry — first FTXUI frames often report 0x0.
596 0 : if (cols <= 0 || rows <= 0)
597 : {
598 0 : if (pinned_cols > 0 && pinned_rows > 0) return pinned;
599 : return LayoutMode::DETAILED;
600 : }
601 :
602 0 : constexpr int k_col_hysteresis = 12;
603 0 : constexpr int k_row_hysteresis = 4;
604 :
605 0 : if (pinned_cols <= 0 || pinned_rows <= 0)
606 : {
607 0 : pinned = resolve_effective_layout(LayoutMode::AUTO, cols, rows);
608 0 : pinned_cols = cols;
609 0 : pinned_rows = rows;
610 0 : return pinned;
611 : }
612 :
613 0 : const int dcols = cols > pinned_cols ? cols - pinned_cols : pinned_cols - cols;
614 0 : const int drows = rows > pinned_rows ? rows - pinned_rows : pinned_rows - rows;
615 0 : if (dcols >= k_col_hysteresis || drows >= k_row_hysteresis)
616 : {
617 0 : pinned = resolve_effective_layout(LayoutMode::AUTO, cols, rows);
618 0 : pinned_cols = cols;
619 0 : pinned_rows = rows;
620 : }
621 0 : return pinned;
622 : }
623 :
624 0 : ftxui::Element render_queue_and_detail(const AppState &state)
625 : {
626 0 : using namespace ftxui;
627 : // Status / Rx panel sits to the right of the repo list.
628 0 : return hbox({
629 : render_queue_table_panel(state),
630 : separator(),
631 : render_repo_detail_panel(state),
632 0 : });
633 : }
634 :
635 0 : ftxui::Element render_dashboard_for_layout(const AppState &state, LayoutMode effective_layout)
636 : {
637 0 : using namespace ftxui;
638 :
639 0 : const auto title = main_window_title(state.get_layout_mode(), effective_layout);
640 :
641 0 : switch (effective_layout)
642 : {
643 0 : case LayoutMode::COMPACT: return render_compact_dashboard(state, effective_layout);
644 :
645 0 : case LayoutMode::BALANCED:
646 0 : return framed_main_window(title, vbox({
647 : render_worker_rows(state),
648 : separator(),
649 : render_queue_and_detail(state),
650 : separator(),
651 : render_summary_and_output(state),
652 0 : }));
653 :
654 0 : case LayoutMode::DETAILED:
655 0 : return framed_main_window(title, vbox({
656 : render_worker_rows(state),
657 : separator(),
658 : render_queue_and_detail(state),
659 : separator(),
660 : render_summary_and_output(state),
661 0 : }));
662 :
663 0 : case LayoutMode::QUEUE_FIRST:
664 0 : default:
665 0 : return framed_main_window(title, vbox({
666 : render_worker_strip_panel(state),
667 : separator(),
668 : render_queue_and_detail(state),
669 : separator(),
670 : render_summary_and_output(state),
671 0 : }));
672 : }
673 0 : }
674 :
675 :
676 : } // namespace
677 :
678 : namespace
679 : {
680 :
681 0 : void tui_debug_log(const std::string &line)
682 : {
683 0 : const char *tmp = std::getenv("TEMP");
684 0 : if (tmp == nullptr) tmp = std::getenv("TMP");
685 0 : if (tmp == nullptr) tmp = ".";
686 0 : const std::string path = std::string(tmp) + "\\esysrepo-tui-debug.log";
687 0 : std::ofstream out(path, std::ios::app);
688 0 : if (!out) return;
689 0 : out << line << '\n';
690 0 : }
691 :
692 : } // namespace
693 :
694 0 : Result run_compact_sync_tui(SyncTuiWork work, const std::vector<std::string> &repo_names,
695 : std::size_t worker_count, std::shared_ptr<log::Logger_if> logger,
696 : const SyncTuiOptions &options)
697 : {
698 0 : using namespace ftxui;
699 0 : using namespace std::chrono_literals;
700 :
701 0 : if (!work) return ESYSREPO_RESULT(ResultCode::GENERIC_ERROR);
702 :
703 0 : const bool demo_mode = options.demo;
704 0 : const int demo_hold_ms = options.demo_banner_hold_ms > 0 ? options.demo_banner_hold_ms : 3000;
705 0 : const std::string demo_banner_text =
706 0 : options.demo_banner_text.empty() ? std::string("Press Tab to cycle views") : options.demo_banner_text;
707 :
708 0 : tui_debug_log("run_compact_sync_tui: enter repos=" + std::to_string(repo_names.size())
709 0 : + " workers=" + std::to_string(worker_count)
710 0 : + " demo=" + (demo_mode ? "1" : "0"));
711 :
712 : // Mute console sink so INFO cannot scroll the alternate screen; file sinks keep INFO.
713 : // CaptureLogger mirrors lines into the Output pane.
714 0 : auto logger_base = std::dynamic_pointer_cast<log::LoggerBase>(logger);
715 0 : const bool muted_console = logger_base != nullptr;
716 0 : if (muted_console) logger_base->set_console_log_level(log::Level::OFF);
717 :
718 0 : EventQueue queue;
719 0 : AppModel model;
720 0 : progress::ProgressSession session;
721 0 : QueueObserver observer(&queue);
722 :
723 0 : auto log_buffer = std::make_shared<LogBuffer>();
724 0 : model.set_log_buffer(log_buffer);
725 0 : model.set_phase_text("Starting…");
726 :
727 : // Lock geometry for the whole session. Fullscreen re-measures every frame and
728 : // the footer visibly jumps when dimy changes (bottom goes up, then down).
729 0 : int screen_cols = 80;
730 0 : int screen_rows = 24;
731 0 : if (!query_console_size(screen_cols, screen_rows))
732 : {
733 0 : screen_cols = 80;
734 0 : screen_rows = 24;
735 : }
736 0 : if (screen_cols < 40) screen_cols = 40;
737 0 : if (screen_rows < 12) screen_rows = 12;
738 :
739 0 : auto screen = ScreenInteractive::FixedSize(screen_cols, screen_rows);
740 0 : screen.TrackMouse(false);
741 0 : prepare_console_for_tui();
742 :
743 0 : std::atomic_bool ui_dirty{true};
744 0 : auto capture_logger = std::make_shared<CaptureLogger>(logger, log_buffer, [&screen, &ui_dirty]() {
745 0 : ui_dirty.store(true);
746 0 : screen.PostEvent(Event::Custom);
747 0 : });
748 :
749 0 : LayoutMode pinned_auto = resolve_effective_layout(LayoutMode::AUTO, screen_cols, screen_rows);
750 0 : int pinned_cols = screen_cols;
751 0 : int pinned_rows = screen_rows;
752 0 : model.set_terminal_size(screen_cols, screen_rows);
753 : // Keep Output pane compact but readable; leave room for workers/footer.
754 0 : model.get_state().set_log_pane_height((std::clamp)(screen_rows / 5, 4, 10));
755 :
756 0 : observer.set_wakeup([&screen, &ui_dirty](const progress::SyncEvent &event) {
757 0 : ui_dirty.store(true);
758 0 : const bool immediate = std::visit(
759 : [](const auto &e) -> bool {
760 : using T = std::decay_t<decltype(e)>;
761 : // Progress/scheduler stay coalesced on the timer; lifecycle is immediate.
762 : return std::is_same_v<T, progress::RepoAssignedEvent> || std::is_same_v<T, progress::RepoDoneEvent>
763 : || std::is_same_v<T, progress::RepoFailedEvent>;
764 : },
765 : event);
766 0 : if (immediate) screen.PostEvent(Event::Custom);
767 0 : });
768 0 : session.add_observer(&observer);
769 :
770 0 : const std::size_t ui_workers = worker_count == 0 ? 1 : worker_count;
771 0 : if (repo_names.empty())
772 0 : model.reset(0, ui_workers);
773 : else
774 0 : model.reset(repo_names, ui_workers);
775 :
776 0 : SyncTuiControl control;
777 0 : control.logger = capture_logger;
778 0 : control.set_phase = [&screen, &model, &ui_dirty](std::string phase) {
779 0 : auto done = std::make_shared<std::promise<void>>();
780 0 : auto fut = done->get_future();
781 0 : screen.Post([&model, &ui_dirty, phase = std::move(phase), done]() mutable {
782 0 : model.set_phase_text(std::move(phase));
783 0 : ui_dirty.store(true);
784 0 : done->set_value();
785 0 : });
786 0 : screen.PostEvent(Event::Custom);
787 0 : fut.wait();
788 0 : };
789 0 : control.reset_repos = [&screen, &model, &ui_dirty](std::vector<std::string> names, std::size_t workers) {
790 0 : auto done = std::make_shared<std::promise<void>>();
791 0 : auto fut = done->get_future();
792 0 : screen.Post([&model, &ui_dirty, names = std::move(names), workers, done]() mutable {
793 0 : model.reset(names, workers == 0 ? 1 : workers);
794 0 : ui_dirty.store(true);
795 0 : done->set_value();
796 0 : });
797 0 : screen.PostEvent(Event::Custom);
798 0 : fut.wait();
799 0 : };
800 :
801 0 : Result sync_result = ESYSREPO_RESULT(ResultCode::OK);
802 0 : std::atomic_bool sync_done{false};
803 0 : std::atomic_bool keep_redraw_timer{true};
804 0 : std::atomic_bool allow_sync_work{false};
805 0 : std::atomic_bool demo_banner_visible{demo_mode};
806 0 : auto exit_loop = screen.ExitLoopClosure();
807 :
808 0 : std::thread demo_banner_timer;
809 0 : if (demo_mode)
810 : {
811 0 : demo_banner_timer = std::thread([&screen, &ui_dirty, &demo_banner_visible, &keep_redraw_timer, demo_hold_ms]() {
812 0 : const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(demo_hold_ms);
813 0 : while (keep_redraw_timer.load() && demo_banner_visible.load())
814 : {
815 0 : if (std::chrono::steady_clock::now() >= deadline)
816 : {
817 0 : demo_banner_visible.store(false);
818 0 : ui_dirty.store(true);
819 0 : screen.PostEvent(Event::Custom);
820 0 : return;
821 : }
822 : // Keep painting while the banner is up (progress may be quiet).
823 0 : ui_dirty.store(true);
824 0 : screen.PostEvent(Event::Custom);
825 0 : std::this_thread::sleep_for(100ms);
826 : }
827 0 : });
828 : }
829 :
830 : // Do not start git work until the first clean frame has been painted; otherwise
831 : // assign/progress events race the first draw and produce a visible startup flash.
832 0 : std::thread worker([&work, &session, &control, &sync_result, &sync_done, &screen, &ui_dirty, &allow_sync_work]() {
833 0 : while (!allow_sync_work.load())
834 0 : std::this_thread::sleep_for(2ms);
835 :
836 0 : try
837 : {
838 0 : sync_result = work(session, control);
839 : }
840 0 : catch (const std::exception &ex)
841 : {
842 0 : tui_debug_log(std::string("worker exception: ") + ex.what());
843 0 : sync_result = ESYSREPO_RESULT(ResultCode::GENERIC_ERROR);
844 0 : }
845 0 : catch (...)
846 : {
847 0 : tui_debug_log("worker unknown exception");
848 0 : sync_result = ESYSREPO_RESULT(ResultCode::GENERIC_ERROR);
849 0 : }
850 0 : sync_done.store(true);
851 0 : ui_dirty.store(true);
852 0 : tui_debug_log("worker: sync finished, waiting for q/Esc");
853 0 : screen.PostEvent(Event::Custom);
854 0 : });
855 :
856 : // Coalesce chatty progress onto a timer; do not paint every tick unconditionally.
857 0 : constexpr auto redraw_period = 100ms;
858 0 : std::thread redraw_timer([&screen, &keep_redraw_timer, &ui_dirty, &allow_sync_work, redraw_period]() {
859 0 : while (keep_redraw_timer.load())
860 : {
861 0 : std::this_thread::sleep_for(redraw_period);
862 0 : if (!allow_sync_work.load()) continue;
863 0 : if (ui_dirty.exchange(false)) screen.PostEvent(Event::Custom);
864 : }
865 0 : });
866 :
867 0 : std::atomic_bool first_frame_posted{false};
868 :
869 0 : auto component = Renderer([&] {
870 0 : model.drain_and_apply(queue);
871 0 : model.sync_log_buffer();
872 :
873 : // FixedSize screen: always use the locked geometry (ignore transient 0x0).
874 0 : const int cols = pinned_cols > 0 ? pinned_cols : screen.dimx();
875 0 : const int rows = pinned_rows > 0 ? pinned_rows : screen.dimy();
876 0 : if (cols > 0 && rows > 0) model.set_terminal_size(cols, rows);
877 :
878 0 : const auto effective =
879 0 : resolve_layout_stable(model.get_state().get_layout_mode(), cols, rows, pinned_auto, pinned_cols, pinned_rows);
880 0 : auto body = render_dashboard_for_layout(model.get_state(), effective);
881 :
882 0 : std::string footer_text;
883 0 : if (sync_done.load())
884 0 : footer_text = "Sync finished — press q or Esc to exit";
885 0 : else if (demo_banner_visible.load())
886 0 : footer_text = demo_banner_text;
887 0 : else if (!model.get_state().get_phase_text().empty())
888 0 : footer_text = model.get_state().get_phase_text();
889 : else
890 0 : footer_text = "Sync in progress…";
891 :
892 : // After the first frame is scheduled, release the sync worker (next task tick).
893 0 : if (!first_frame_posted.exchange(true))
894 : {
895 0 : screen.Post([&allow_sync_work] { allow_sync_work.store(true); });
896 : }
897 :
898 : // Pin the whole frame to the locked terminal size so flex cannot move the footer.
899 0 : Element frame = vbox({
900 : body | flex,
901 : separator(),
902 0 : text(footer_text) | (sync_done.load() ? bold : nothing),
903 : })
904 0 : | size(WIDTH, EQUAL, cols) | size(HEIGHT, EQUAL, rows);
905 :
906 0 : if (demo_banner_visible.load()) frame = with_demo_banner(std::move(frame), demo_banner_text);
907 0 : return frame;
908 0 : });
909 :
910 0 : component = component | CatchEvent([&](Event event) {
911 0 : if (sync_done.load() && (event == Event::Escape || event == Event::q || event == Event::Q))
912 : {
913 0 : exit_loop();
914 0 : return true;
915 : }
916 :
917 0 : const auto &st = model.get_state();
918 0 : const int repo_count = static_cast<int>(st.get_repos().size());
919 0 : const int worker_count_local = static_cast<int>(st.get_workers().size());
920 :
921 0 : if (event == Event::PageUp)
922 : {
923 0 : model.scroll_log_page_up();
924 0 : screen.PostEvent(Event::Custom);
925 0 : return true;
926 : }
927 0 : if (event == Event::PageDown)
928 : {
929 0 : model.scroll_log_page_down();
930 0 : screen.PostEvent(Event::Custom);
931 0 : return true;
932 : }
933 :
934 0 : if (event == Event::Tab)
935 : {
936 : // Demo recordings: hold Tab until the on-screen hint has dismissed.
937 0 : if (demo_banner_visible.load()) return true;
938 0 : model.cycle_layout_mode();
939 0 : screen.PostEvent(Event::Custom);
940 0 : return true;
941 : }
942 :
943 0 : if (event == Event::a || event == Event::A)
944 : {
945 0 : model.set_layout_mode(LayoutMode::AUTO);
946 : // Keep pinned geometry; only recompute mode for the locked size.
947 0 : pinned_auto = resolve_effective_layout(LayoutMode::AUTO, pinned_cols, pinned_rows);
948 0 : screen.PostEvent(Event::Custom);
949 0 : return true;
950 : }
951 :
952 0 : if (event == Event::f || event == Event::F)
953 : {
954 0 : model.cycle_queue_filter();
955 0 : screen.PostEvent(Event::Custom);
956 0 : return true;
957 : }
958 :
959 0 : if (repo_count > 0 && event == Event::ArrowUp)
960 : {
961 0 : const int idx = (st.get_selected_repo() - 1 + repo_count) % repo_count;
962 0 : model.set_selected_repo(idx);
963 0 : screen.PostEvent(Event::Custom);
964 0 : return true;
965 : }
966 0 : if (repo_count > 0 && event == Event::ArrowDown)
967 : {
968 0 : const int idx = (st.get_selected_repo() + 1) % repo_count;
969 0 : model.set_selected_repo(idx);
970 0 : screen.PostEvent(Event::Custom);
971 0 : return true;
972 : }
973 :
974 0 : if (worker_count_local > 0 && event == Event::ArrowLeft)
975 : {
976 0 : const int idx = (st.get_selected_worker() - 1 + worker_count_local) % worker_count_local;
977 0 : model.set_selected_worker(idx);
978 0 : screen.PostEvent(Event::Custom);
979 0 : return true;
980 : }
981 0 : if (worker_count_local > 0 && event == Event::ArrowRight)
982 : {
983 0 : const int idx = (st.get_selected_worker() + 1) % worker_count_local;
984 0 : model.set_selected_worker(idx);
985 0 : screen.PostEvent(Event::Custom);
986 0 : return true;
987 : }
988 :
989 : return false;
990 0 : });
991 :
992 0 : tui_debug_log("entering ScreenInteractive::Loop");
993 0 : screen.Loop(component);
994 0 : tui_debug_log("left ScreenInteractive::Loop");
995 0 : allow_sync_work.store(true); // unblock worker if Loop exited before first frame
996 0 : keep_redraw_timer.store(false);
997 0 : demo_banner_visible.store(false);
998 0 : redraw_timer.join();
999 0 : if (demo_banner_timer.joinable()) demo_banner_timer.join();
1000 0 : worker.join();
1001 :
1002 0 : if (muted_console) logger_base->set_console_log_level(log::Level::INFO);
1003 :
1004 0 : restore_console_after_tui();
1005 0 : return sync_result;
1006 0 : }
1007 :
1008 : } // namespace esys::repo::tui
1009 :
1010 : #endif // ESYSREPO_HAVE_TUI
|