mod commands;
mod components;
mod keys;
mod page;
mod pages;
#[cfg(test)]
mod screenshots;
mod settings;
mod shell;
mod sidebar;
mod store;
use eframe::egui::{self, Frame, Margin};
use crate::docker::{Action, Task};
use crate::theme;
use crate::worker::{Request, Worker};
use commands::Target;
use components::confirm::{self, Choice};
use components::palette::{CommandPalette, Outcome};
use components::toast::Toast;
use components::{shortcuts, table};
use keys::Shortcut;
use page::{CommandRun, Confirmation, Intent, Page, PageCtx};
use store::{Connection, Live, Store};
pub struct FerrodockApp {
worker: Worker,
store: Store,
pages: Vec<Box<dyn Page>>,
active: usize,
search: String,
toast: Option<Toast>,
confirmation: Option<Confirmation>,
watching: Option<(String, bool)>,
palette: Option<CommandPalette>,
recent: Vec<String>,
show_help: bool,
settings: settings::Settings,
show_settings: bool,
updater: crate::update::Updater,
}
impl FerrodockApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
theme::install(&cc.egui_ctx);
let settings: settings::Settings = cc
.storage
.and_then(|s| eframe::get_value(s, settings::STORAGE_KEY))
.unwrap_or_default();
settings.apply(&cc.egui_ctx);
let updater = {
let ctx = cc.egui_ctx.clone();
crate::update::Updater::new(move || ctx.request_repaint())
};
if settings.check_updates {
updater.check();
}
let ctx = cc.egui_ctx.clone();
let worker = Worker::spawn(move || ctx.request_repaint());
let pages = pages::all();
for page in &pages {
worker.send(Request::Refresh(page.resource()));
}
worker.send(Request::Poll(pages[0].resource()));
Self {
worker,
store: Store::new(),
pages,
active: 0,
search: String::new(),
toast: None,
confirmation: None,
watching: None,
palette: None,
recent: Vec::new(),
show_help: false,
settings,
show_settings: false,
updater,
}
}
fn refresh(&mut self) {
let resource = self.pages[self.active].resource();
self.worker.send(Request::Refresh(resource));
}
fn open_page(&mut self, index: usize) {
if index != self.active {
self.pages[self.active].clear_selection();
self.active = index;
self.search.clear();
self.worker
.send(Request::Poll(self.pages[index].resource()));
}
}
fn handle_responses(&mut self) {
while let Some(response) = self.worker.try_recv() {
if let Some(notice) = self.store.apply(response) {
self.toast = Some(Toast::new(notice.text, notice.is_error));
}
}
}
fn sync_watch(&mut self) {
let wanted = self.pages[self.active]
.watched_container(&self.store)
.map(|id| {
let running = self.store.container(&id).is_some_and(|c| c.is_running());
(id, running)
});
if wanted == self.watching {
return;
}
let id = wanted.as_ref().map(|(id, _)| id.clone());
self.store.live = id.clone().map(Live::new);
self.worker.send(Request::Watch(id));
self.watching = wanted;
}
fn overlay_open(&self) -> bool {
self.palette.is_some()
|| self.pages[self.active].has_overlay()
|| self.show_help
|| self.show_settings
|| self.confirmation.is_some()
}
fn handle_shortcut(
&mut self,
ctx: &egui::Context,
p: &'static theme::Palette,
shortcut: Shortcut,
) {
match shortcut {
Shortcut::Palette => self.palette = Some(CommandPalette::default()),
Shortcut::Help => self.show_help = true,
Shortcut::Settings => self.show_settings = true,
Shortcut::SelectAll => {
let mut intents = Vec::new();
let page_ctx = PageCtx::new(&self.store, p, &self.search, &mut intents);
self.pages[self.active].select_all(&page_ctx);
}
Shortcut::Page(index) if index < self.pages.len() => self.open_page(index),
Shortcut::Page(_) => {}
Shortcut::Step(delta) => table::request_step(ctx, delta),
Shortcut::CloseDetails => self.pages[self.active].clear_selection(),
Shortcut::FocusSearch => ctx.memory_mut(|m| m.request_focus(shell::search_id())),
Shortcut::Refresh => self.refresh(),
Shortcut::PageKey(key) => {
let mut intents = Vec::new();
let mut page_ctx = PageCtx::new(&self.store, p, &self.search, &mut intents);
self.pages[self.active].shortcut(key, &mut page_ctx);
intents.into_iter().for_each(|i| self.handle_intent(i));
}
}
}
fn show_palette(&mut self, ui: &mut egui::Ui, p: &theme::Palette) {
let Some(palette) = &mut self.palette else {
return;
};
let (entries, mut targets): (Vec<_>, Vec<_>) =
commands::build(&self.pages, &self.store, &self.recent)
.into_iter()
.map(|item| (item.entry, item.target))
.unzip();
match palette.show(ui, p, &entries) {
Some(Outcome::Chosen(index)) => {
self.palette = None;
commands::remember(&mut self.recent, &entries[index].label);
self.run_target(ui.ctx(), targets.swap_remove(index));
}
Some(Outcome::Closed) => self.palette = None,
None => {}
}
}
fn run_target(&mut self, ctx: &egui::Context, target: Target) {
match target {
Target::OpenPage(index) => self.open_page(index),
Target::Page(index, run) => {
self.open_page(index);
match run {
CommandRun::Select(id) => {
self.pages[index].select(&id);
table::request_step(ctx, 0);
}
CommandRun::Intents(intents) => {
intents.into_iter().for_each(|i| self.handle_intent(i));
}
}
}
Target::Refresh => self.refresh(),
Target::Help => self.show_help = true,
Target::Settings => self.show_settings = true,
}
}
fn handle_intent(&mut self, intent: Intent) {
match intent {
Intent::Run(task) => self.run(task),
Intent::Confirm(confirmation) => self.confirmation = Some(confirmation),
Intent::Pull(image) => {
self.store.pending.insert(image.clone(), Action::Pull);
self.worker.send(Request::Pull(image));
}
Intent::RunImage(spec) => self.worker.send(Request::RunImage(spec)),
Intent::Files(op) => {
self.store.files_requested(&op);
self.worker.send(Request::Files(op));
}
Intent::Change(change) => {
let task = change.task();
self.store.pending.insert(task.id, task.action);
self.worker.send(Request::Change(change));
}
Intent::Processes(id) => {
let previous = self
.store
.processes
.take()
.filter(|(asked, _, _)| *asked == id)
.and_then(|(_, _, table)| table);
self.store.processes = Some((id.clone(), std::time::Instant::now(), previous));
self.worker.send(Request::Processes(id));
}
Intent::History(image) => {
self.store.history = Some((image.clone(), None));
self.worker.send(Request::History(image));
}
Intent::Notify { text, is_error } => self.toast = Some(Toast::new(text, is_error)),
Intent::Open { page, id } => {
if let Some(index) = self.pages.iter().position(|p| p.title() == page) {
self.open_page(index);
self.pages[index].select(&id);
}
}
Intent::OpenPage(title) => {
if let Some(index) = self.pages.iter().position(|p| p.title() == title) {
self.open_page(index);
}
}
Intent::ClearSearch => self.search.clear(),
Intent::Inspect(resource, id) => {
self.store.inspection = None;
self.store.inspecting = Some(id.clone());
self.worker.send(Request::Inspect(resource, id));
}
}
}
fn run(&mut self, task: Task) {
self.store.pending.insert(task.id.clone(), task.action);
self.worker.send(Request::Run(task));
}
fn show_confirmation(&mut self, ui: &mut egui::Ui, p: &theme::Palette) {
let Some(confirmation) = &self.confirmation else {
return;
};
match confirm::show(ui, p, confirmation) {
Some(Choice::Confirm) => {
if let Some(confirmation) = self.confirmation.take() {
confirmation
.tasks
.into_iter()
.for_each(|task| self.run(task));
}
}
Some(Choice::Cancel) => self.confirmation = None,
None => {}
}
}
}
impl eframe::App for FerrodockApp {
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, settings::STORAGE_KEY, &self.settings);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.handle_responses();
let p = theme::palette(ui);
if !self.overlay_open() {
for shortcut in keys::read(ui.ctx()) {
self.handle_shortcut(ui.ctx(), p, shortcut);
}
}
let clicked_page = egui::Panel::left("sidebar")
.resizable(false)
.exact_size(210.0)
.frame(Frame::new().fill(p.sidebar).inner_margin(Margin::same(14)))
.show(ui, |ui| {
sidebar::show(
ui,
p,
&self.pages,
self.active,
&self.store,
&self.updater.status(),
)
})
.inner;
match clicked_page {
Some(sidebar::Click::Page(index)) => self.open_page(index),
Some(sidebar::Click::Settings) => self.show_settings = true,
Some(sidebar::Click::Update) => {
if let crate::update::Status::Available(available) = self.updater.status() {
self.updater.install(available);
}
}
Some(sidebar::Click::Restart) => {
if let crate::update::Status::Ready { relaunch, .. } = self.updater.status() {
match relaunch.start() {
Ok(()) => ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close),
Err(e) => {
self.toast = Some(Toast::new(format!("Couldn't restart: {e}"), true));
}
}
}
}
None => {}
}
let mut intents = Vec::new();
let mut refresh = false;
let page = &mut self.pages[self.active];
if page.has_selection(&self.store) {
egui::Panel::right("details")
.default_size(340.0)
.min_size(280.0)
.frame(Frame::new().fill(p.sidebar).inner_margin(Margin::same(18)))
.show(ui, |ui| {
let mut ctx = PageCtx::new(&self.store, p, &self.search, &mut intents);
page.show_details(ui, &mut ctx);
});
}
egui::CentralPanel::default_margins()
.frame(
Frame::new()
.fill(p.bg)
.inner_margin(Margin::symmetric(22, 18)),
)
.show(ui, |ui| {
let summary = page.summary(&self.store);
let mut search = self.search.clone();
refresh = shell::header(
ui,
p,
page.title(),
&summary,
page.search_hint(),
&mut search,
|ui| {
if !self.store.is_disconnected() {
let mut ctx = PageCtx::new(&self.store, p, &self.search, &mut intents);
page.toolbar(ui, &mut ctx);
}
},
);
self.search = search;
ui.add_space(12.0);
if let Connection::Disconnected { reason } = &self.store.connection {
refresh |= shell::disconnected(ui, p, reason);
} else if matches!(self.store.connection, Connection::Connecting) {
shell::connecting(ui, p);
} else {
let mut ctx = PageCtx::new(&self.store, p, &self.search, &mut intents);
page.show(ui, &mut ctx);
}
});
for intent in intents {
self.handle_intent(intent);
}
self.sync_watch();
if refresh {
self.refresh();
}
table::clear_step(ui.ctx());
self.show_confirmation(ui, p);
self.show_palette(ui, p);
if self.show_settings {
self.show_settings = settings::show(ui, p, &mut self.settings, &self.updater);
}
if self.show_help {
let titles: Vec<&'static str> = self.pages.iter().map(|page| page.title()).collect();
self.show_help = shortcuts::show(ui, p, &keys::help(&titles));
}
if let Some(toast) = &self.toast
&& !toast.show(ui, p)
{
self.toast = None;
}
}
}