1use qframe::date::DateTime;
4use qframe::prelude::*;
5use qframe::widgets::{
6 Click, ContextItem, ContextMenu, FileManager, FileManagerState, MenuTarget, Popover, RowMark, Section, ShimmerText,
7 Spinner, Switch, Tooltip, WidgetDock,
8};
9
10use crate::engine::ContainerState;
11
12use super::files;
13use super::{Msg, OpenWorkspace, WorkspaceScreen};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PanelWidget {
21 Files,
23 Info,
25 Containers,
27}
28
29impl PanelWidget {
30 pub const ALL: [Self; 3] = [Self::Files, Self::Info, Self::Containers];
32
33 #[must_use]
35 pub fn key(self) -> &'static str {
36 match self {
37 Self::Files => "files",
38 Self::Info => "info",
39 Self::Containers => "containers",
40 }
41 }
42
43 #[must_use]
45 pub fn icon(self) -> &'static str {
46 match self {
47 Self::Files => "folder",
48 Self::Info => "info",
49 Self::Containers => "inbox",
50 }
51 }
52
53 #[must_use]
55 pub fn title(self) -> String {
56 t!(&format!("workspace.widget.{}", self.key()))
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Panel {
64 open: bool,
65 width: u16,
66 order: Vec<PanelWidget>,
67 opened: Vec<PanelWidget>,
68 chooser: bool,
69 chooser_row: usize,
71}
72
73impl Default for Panel {
74 fn default() -> Self {
75 Self {
76 open: true,
77 width: super::PANEL_WIDTH,
78 order: PanelWidget::ALL.to_vec(),
79 opened: vec![PanelWidget::Files, PanelWidget::Containers],
80 chooser: false,
81 chooser_row: 0,
82 }
83 }
84}
85
86impl Panel {
87 #[must_use]
89 pub fn is_open(&self) -> bool {
90 self.open
91 }
92
93 #[must_use]
95 pub fn width(&self) -> u16 {
96 self.width
97 }
98
99 #[must_use]
101 pub fn order(&self) -> &[PanelWidget] {
102 &self.order
103 }
104
105 #[must_use]
107 pub fn is_unfolded(&self, index: usize) -> bool {
108 self.order.get(index).is_some_and(|widget| self.opened.contains(widget))
109 }
110
111 pub fn set_open(&mut self, open: bool) {
113 self.open = open;
114 }
115
116 pub fn set_width(&mut self, width: u16) {
118 self.width = width;
119 }
120
121 pub fn toggle(&mut self, index: usize, open: bool) {
123 let Some(widget) = self.order.get(index).copied() else { return };
124 self.opened.retain(|shown| *shown != widget);
125 if open {
126 self.opened.push(widget);
127 }
128 }
129
130 pub fn move_widget(&mut self, from: usize, to: usize) {
132 if from >= self.order.len() || to >= self.order.len() {
133 return;
134 }
135 let widget = self.order.remove(from);
136 self.order.insert(to, widget);
137 }
138
139 pub fn show_chooser(&mut self, show: bool) {
142 self.chooser = show;
143 self.chooser_row = 0;
144 }
145
146 pub fn highlight(&mut self, row: usize) {
148 self.chooser_row = row;
149 }
150
151 #[must_use]
153 pub fn chooser_open(&self) -> bool {
154 self.chooser
155 }
156
157 pub fn add(&mut self, widget: PanelWidget) {
161 if !self.order.contains(&widget) {
162 self.order.push(widget);
163 self.opened.push(widget);
164 }
165 self.chooser = false;
166 }
167
168 pub fn remove(&mut self, widget: PanelWidget) {
170 self.order.retain(|shown| *shown != widget);
171 self.opened.retain(|shown| *shown != widget);
172 }
173
174 #[must_use]
176 pub fn missing(&self) -> Vec<PanelWidget> {
177 PanelWidget::ALL.into_iter().filter(|widget| !self.carries(*widget)).collect()
178 }
179
180 #[must_use]
182 pub fn carries(&self, widget: PanelWidget) -> bool {
183 self.order.contains(&widget)
184 }
185}
186
187pub(super) const CHOOSER_ID: &str = "workspace-panel-chooser";
189
190pub(super) fn view(screen: &WorkspaceScreen, ui: &mut View<'_, Msg>) {
192 let panel = screen.panel();
193 ui.column(|ui| {
194 ui.row(|ui| {
195 ui.add(Text::new(t!("workspace.panel.title")).role("title").no_wrap());
196 ui.spacer();
197 chooser(panel, ui);
198 })
199 .fill_width();
200 dock(screen, ui);
201 super::backups::view(screen, ui);
202 })
203 .gap(1)
204 .padding(Padding::symmetric(1, 1))
205 .fill();
206}
207
208fn chooser(panel: &Panel, ui: &mut View<'_, Msg>) {
213 let missing = panel.missing();
214 if missing.is_empty() {
215 return;
216 }
217 let open = panel.chooser_open();
218 Popover::new(open)
219 .on_dismiss(Msg::ShowWidgets(false))
220 .anchor(|ui| {
221 let plus = ui.env().icons().glyph("add").into_owned();
224 ui.add_with(Tooltip::new(t!("workspace.panel.add")).on_focus(true), |ui| {
225 ui.add(Button::new(plus).on_press(Msg::ShowWidgets(!open))).id("workspace-panel-add");
226 });
227 })
228 .content(|ui| {
229 let items = missing.iter().map(|widget| ListItem::new(widget.title()).icon(widget.icon(), None));
230 let offered = missing.clone();
231 let row = panel.chooser_row.min(missing.len() - 1);
232 let list = List::new(items)
233 .selected(Some(row))
234 .on_select(Msg::HighlightWidget)
235 .on_activate(move |index| Msg::AddWidget(offered[index]));
236 ui.add(list.wrap(true)).id(CHOOSER_ID).width(Length::Cells(26));
237 })
238 .show(ui);
239}
240
241fn dock(screen: &WorkspaceScreen, ui: &mut View<'_, Msg>) {
243 let panel = screen.panel();
244 let sections = panel.order().iter().map(|widget| Section::new(widget.title()).icon(widget.icon()));
245 let open: Vec<bool> = (0..panel.order().len()).map(|index| panel.is_unfolded(index)).collect();
246 let dock = WidgetDock::new(sections)
247 .open(open)
248 .empty_text(t!("workspace.panel.none"))
249 .on_toggle(Msg::ToggleWidget)
250 .on_move(move |from, to| Msg::MoveWidget { from, to });
251 let removals = panel.order().iter().map(|widget| {
255 ContextItem::new(t!("workspace.panel.remove", widget = widget.title()), Msg::RemoveWidget(*widget))
256 .icon(widget.icon())
257 });
258 ui.add_with(ContextMenu::new(removals), |ui| {
259 ui.add_with(dock, |ui| {
260 for widget in panel.order() {
261 ui.column(|ui| body(screen, *widget, ui)).fill().id(widget.key());
262 }
263 })
264 .fill()
265 .id("workspace-panel-dock");
266 })
267 .fill();
268}
269
270fn body(screen: &WorkspaceScreen, widget: PanelWidget, ui: &mut View<'_, Msg>) {
272 let Some(workspace) = screen.workspace() else { return };
273 match widget {
274 PanelWidget::Files => files_widget(workspace, screen.engine().is_some(), ui),
275 PanelWidget::Info => info_widget(screen, workspace, ui),
276 PanelWidget::Containers => containers_widget(screen, workspace, ui),
277 }
278}
279
280fn files_widget(workspace: &OpenWorkspace, engine: bool, ui: &mut View<'_, Msg>) {
283 let tree = workspace.files();
284 if tree.children(FileManagerState::ROOT).is_none() && tree.error().is_none() {
285 ui.add(Spinner::new().label(t!("workspace.files.reading")));
287 return;
288 }
289 if let Some(problem) = tree.error() {
290 ui.add(Text::new(t!("workspace.files.unreadable")).role("secondary"));
292 ui.add(Text::new(problem.to_owned()).role("faint")).selectable(true);
293 return;
294 }
295 let skip = workspace.backup_skip().to_vec();
296 let marked = skip.clone();
297 let folders = tree.folder_keys();
298 let root = tree.root().to_path_buf();
299 FileManager::new(tree, files::wrap(workspace.id()))
300 .root_label(workspace.name().to_owned())
301 .open_on(Click::Single)
304 .on_open(move |path| Msg::OpenFile(files::key_of(&root, path)))
305 .menu_for(move |target| own_items(target, &skip, engine))
306 .row_mark(move |key| mark(key, folders.contains(key), &marked))
307 .id(FILES_ID)
308 .show(ui)
309 .fill();
310}
311
312pub(super) const FILES_ID: &str = "workspace-files";
314
315const LEFT_OUT_TONE: &str = "warning";
317
318fn mark(key: &str, folder: bool, skip: &[String]) -> RowMark {
327 if key == FileManagerState::ROOT {
328 return RowMark::new().plain_sign("workspace");
329 }
330 if super::backups::is_left_out(skip, key) {
331 let icon = if folder { "folder" } else { "file" };
332 return RowMark::new().sign(icon, LEFT_OUT_TONE).faint(true);
333 }
334 RowMark::new()
335}
336
337fn own_items(target: &MenuTarget<'_>, skip: &[String], engine: bool) -> Vec<ContextItem<Msg>> {
342 let backup = backup_item(target.key, target.selection, skip);
343 if target.selection.len() > 1 || target.folder {
344 return backup.into_iter().collect();
345 }
346 let versions = ContextItem::new(t!("workspace.files.versions"), Msg::ShowBackups(Some(target.key.to_owned())))
347 .disabled(!engine);
348 std::iter::once(versions).chain(backup).collect()
349}
350
351fn backup_item(key: &str, targets: &[String], skip: &[String]) -> Option<ContextItem<Msg>> {
359 let message = |out: bool| Msg::LeaveOut(key.to_owned(), out);
360 if !targets.is_empty() && targets.iter().all(|target| skip.contains(target)) {
361 return Some(ContextItem::new(t!("workspace.files.back-up"), message(false)));
362 }
363 if targets.iter().all(|target| super::backups::is_left_out(skip, target)) {
364 return None;
365 }
366 Some(ContextItem::new(t!("workspace.files.leave-out"), message(true)))
367}
368
369const WIDGET_INSET: u16 = 7;
372
373fn info_widget(screen: &WorkspaceScreen, workspace: &OpenWorkspace, ui: &mut View<'_, Msg>) {
375 let engine = screen
376 .engine()
377 .map_or_else(|| t!("workspace.info.no-engine"), |engine| format!("{:?}", engine.kind()).to_lowercase());
378 let rows = [
379 (t!("workspace.info.name"), workspace.name().to_owned()),
380 (t!("workspace.info.id"), workspace.id().to_owned()),
381 (t!("workspace.info.folder"), workspace.paths().root.display().to_string()),
382 (
383 t!("workspace.info.profiles"),
384 workspace.profiles().iter().filter(|profile| workspace.carries(profile.name.as_str())).count().to_string(),
385 ),
386 (t!("workspace.info.engine"), engine),
387 (t!("workspace.info.backup"), super::backups::last_text(screen, workspace, DateTime::now_local())),
388 (t!("workspace.info.left-out"), super::backups::left_out_text(workspace)),
389 (t!("workspace.info.backup-size"), super::backups::size_text(workspace)),
390 ];
391 let backing_up = super::backups::is_running(screen, workspace);
392 let backup_label = t!("workspace.info.backup");
393 ui.column(|ui| {
394 for (label, value) in rows {
395 if backing_up && label == backup_label {
398 ui.row(|ui| {
399 ui.add(Text::new(format!("{label} ")).role("faint"));
400 ui.add(ShimmerText::new(t!("workspace.backup.running"))).id("workspace-backing-up");
401 });
402 continue;
403 }
404 ui.add(Text::rich([Span::new(format!("{label} ")).role("faint"), Span::new(value)]));
405 }
406 })
407 .fill_width()
408 .selectable(true);
409 ui.add(Switch::new(workspace.backs_up_assets()).label(t!("workspace.backup.assets")).on_toggle(Msg::BackupAssets))
410 .id("workspace-backup-assets");
411 let backups = Button::new(t!("workspace.backup.list")).disabled(screen.engine().is_none());
413 ui.add(backups.on_press(Msg::ShowBackups(None))).id("workspace-backups-open");
414}
415
416fn containers_widget(screen: &WorkspaceScreen, workspace: &OpenWorkspace, ui: &mut View<'_, Msg>) {
418 if screen.engine().is_none() {
419 ui.add(Text::new(t!("workspace.no-engine")).role("secondary"));
420 return;
421 }
422 if let Some(failure) = &workspace.container_error {
423 ui.add(Text::new(t!("workspace.containers.unreadable")).role("secondary"));
424 ui.add(Text::new(failure.output.clone()).role("faint")).selectable(true);
425 }
426 let prefix = format!("qcode-{}-", workspace.id());
429 if workspace.containers().is_empty() {
430 ui.add(Text::new(t!("workspace.containers.empty")).role("secondary")).fill_width().id("workspace-containers");
433 } else {
434 let rows = workspace.containers().iter().map(|container| {
435 let short = container.name.strip_prefix(&prefix).unwrap_or(&container.name);
436 ListItem::new(short.to_owned())
437 .icon("dot", Some(tone(&container.state)))
438 .detail(state_text(&container.state, screen.freezing.frozen.contains(&container.name)))
442 });
443 let list = List::new(rows).selected(Some(workspace.container_row)).on_select(Msg::SelectContainer);
444 ui.add(list.wrap(true)).fill().id("workspace-containers");
445 }
446
447 let selected = workspace.containers().get(workspace.container_row);
448 let name = selected.map(|container| container.name.clone());
449 let up = selected.is_some_and(|container| container.state.is_up());
453 let busy = workspace.busy;
454 let labels =
455 [t!("workspace.containers.stop"), t!("workspace.containers.restart"), t!("workspace.containers.refresh")];
456 let buttons = |ui: &mut View<'_, Msg>| {
457 let stop = name.clone().map(Msg::StopContainer);
458 let mut button = Button::new(labels[0].clone()).disabled(!up || busy);
459 if let Some(message) = stop {
460 button = button.on_press(message);
461 }
462 ui.add(button).id("workspace-container-stop");
463
464 let restart = name.clone().map(Msg::RestartContainer);
465 let mut button = Button::new(labels[1].clone()).disabled(selected.is_none() || busy);
466 if let Some(message) = restart {
467 button = button.on_press(message);
468 }
469 ui.add(button).id("workspace-container-restart");
470
471 ui.add(Button::new(labels[2].clone()).loading(busy).disabled(busy).on_press(Msg::RefreshContainers))
473 .id("workspace-container-refresh");
474 };
475 if buttons_width(&labels) <= screen.panel().width().saturating_sub(WIDGET_INSET) {
478 ui.row(buttons).gap(1).fill_width();
479 } else {
480 ui.column(buttons).fill_width();
481 }
482 let profile = workspace.profiles.iter().find(|profile| {
485 name.as_deref() == Some(crate::engine::names::profile_container(workspace.id(), profile.name.as_str()).as_str())
486 });
487 if let Some(profile) = profile {
488 let mut admin = Button::new(t!("workspace.admin.open")).disabled(!up);
489 if up {
490 admin = admin.on_press(Msg::OpenAdmin(profile.name.to_string()));
491 }
492 ui.add(admin).id("workspace-container-admin");
493 }
494}
495
496fn buttons_width(labels: &[String]) -> u16 {
499 let gaps = u16::try_from(labels.len().saturating_sub(1)).unwrap_or(0);
500 labels.iter().fold(gaps, |total, label| total.saturating_add(qframe::text::width(label)).saturating_add(4))
501}
502
503fn tone(state: &ContainerState) -> &'static str {
506 match state {
507 ContainerState::Running => "success",
508 ContainerState::Created | ContainerState::Restarting => "info",
509 ContainerState::Paused => "warning",
510 ContainerState::Dead => "danger",
511 ContainerState::Exited | ContainerState::Removing | ContainerState::Unknown(_) => "muted",
512 }
513}
514
515fn state_text(state: &ContainerState, frozen: bool) -> String {
524 if frozen && matches!(state, ContainerState::Paused) {
525 return t!("workspace.state.frozen");
526 }
527 let key = match state {
528 ContainerState::Created => "created",
529 ContainerState::Running => "running",
530 ContainerState::Paused => "paused",
531 ContainerState::Restarting => "restarting",
532 ContainerState::Removing => "removing",
533 ContainerState::Exited => "exited",
534 ContainerState::Dead => "dead",
535 ContainerState::Unknown(word) => return word.clone(),
536 };
537 t!(&format!("workspace.state.{key}"))
538}