#![deny(unsafe_code)]
#![warn(clippy::pedantic)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod system_theme;
use std::path::PathBuf;
use std::sync::mpsc;
use eframe::egui;
use filebase::detail::Outcome;
use filebase::i18n::{fill, t};
use filebase::query::{self, Run, Update};
use filebase::{Detail, ReadOnly};
const CATALOGUES: &[(&str, &str)] = &[
("de", include_str!("../po/de.po")),
#[cfg(debug_assertions)]
("en-x-pseudo", include_str!("../po/en-x-pseudo.po")),
];
const APP_ID: &str = "filebase";
#[cfg(target_os = "windows")]
const WINDOW_ICON: &[u8] = include_bytes!("../packaging/windows/filebase.ico");
#[cfg(target_os = "windows")]
fn window_icon() -> Option<egui::IconData> {
let directory = ico::IconDir::read(std::io::Cursor::new(WINDOW_ICON)).ok()?;
let entry = directory.entries().iter().find(|e| e.width() == 64)?;
let image = entry.decode().ok()?;
Some(egui::IconData {
rgba: image.rgba_data().to_vec(),
width: image.width(),
height: image.height(),
})
}
const FIRST_QUERY: &str = "select *";
fn main() -> eframe::Result {
if let Some(language) = potext::preferred() {
let _ = filebase::i18n::set_language(&language, CATALOGUES);
let _ = flyleaf::set_language(&language);
}
let opened = std::env::args_os().nth(1).map(PathBuf::from);
let viewport = egui::ViewportBuilder::default()
.with_app_id(APP_ID)
.with_inner_size([1040.0, 680.0])
.with_min_inner_size([680.0, 420.0]);
#[cfg(target_os = "macos")]
let viewport = viewport.with_icon(egui::IconData::default());
#[cfg(target_os = "windows")]
let viewport = match window_icon() {
Some(icon) => viewport.with_icon(icon),
None => viewport,
};
let options = eframe::NativeOptions {
viewport,
..Default::default()
};
eframe::run_native(
"Filebase",
options,
Box::new(move |cc| {
system_theme::follow(&cc.egui_ctx);
let mut app = App::new();
if let Some(folder) = opened {
app.bind(folder, &cc.egui_ctx);
}
Ok(Box::new(app))
}),
)
}
struct Picking {
answer: mpsc::Receiver<Option<PathBuf>>,
}
struct App {
folder: Option<PathBuf>,
recursive: bool,
query: String,
run: Option<Run>,
refused: Option<String>,
selected: Option<Detail>,
scratch: Option<tempfile::TempDir>,
said: Option<String>,
picking: Option<Picking>,
}
impl App {
fn new() -> Self {
Self {
folder: None,
recursive: false,
query: FIRST_QUERY.to_owned(),
run: None,
refused: None,
selected: None,
scratch: None,
said: None,
picking: None,
}
}
fn bind(&mut self, folder: PathBuf, ctx: &egui::Context) {
if !folder.is_dir() {
self.refused = Some(fill(
t("{path} is not a folder."),
&[("path", &folder.display().to_string())],
));
return;
}
self.folder = Some(folder);
self.selected = None;
self.run_query(ctx);
}
fn run_query(&mut self, ctx: &egui::Context) {
let Some(folder) = self.folder.clone() else {
return;
};
self.said = None;
let source = slipql::ast::Source {
root: folder,
recursive: self.recursive,
};
let query = match slipql::parse_with(&self.query, Some(&source)) {
Ok(query) => query,
Err(e) => {
self.refused = Some(e.to_string());
self.run = None;
return;
}
};
self.refused = None;
let repaint = {
let ctx = ctx.clone();
move || ctx.request_repaint()
};
match Run::start(&query, repaint) {
Ok(run) => {
self.run = Some(run);
self.selected = None;
}
Err(e) => {
self.refused = Some(e.to_string());
self.run = None;
}
}
}
fn pick_folder(&mut self) {
if self.picking.is_some() {
return;
}
let (sender, answer) = mpsc::channel();
let start = self.folder.clone();
std::thread::Builder::new()
.name("filebase-dialog".to_owned())
.spawn(move || {
let mut dialog = rfd::FileDialog::new().set_title(t("Choose a folder"));
if let Some(start) = start {
dialog = dialog.set_directory(start);
}
let _ = sender.send(dialog.pick_folder());
})
.ok();
self.picking = Some(Picking { answer });
}
fn open_content_file(&mut self) {
let Some(detail) = &self.selected else { return };
let scratch = match &self.scratch {
Some(dir) => dir,
None => {
match new_scratch() {
Ok(dir) => self.scratch.insert(dir),
Err(e) => {
self.said = Some(fill(
t("Cannot make somewhere to put the content file: {why}"),
&[("why", &e.to_string())],
));
return;
}
}
}
};
match detail.extract_to(scratch.path()) {
Ok(out) => {
if let Err(e) = opener::open(&out) {
self.said = Some(fill(
t("Nothing opened it: {why}"),
&[("why", &e.to_string())],
));
}
}
Err(e) => {
self.said = Some(fill(
t("Cannot read the content file: {why}"),
&[("why", &e.to_string())],
));
}
}
}
}
fn new_scratch() -> std::io::Result<tempfile::TempDir> {
let mut builder = tempfile::Builder::new();
builder.prefix("filebase-");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
builder.permissions(std::fs::Permissions::from_mode(0o700));
}
builder.tempdir()
}
impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
if let Some(picking) = &self.picking {
if let Ok(answer) = picking.answer.try_recv() {
self.picking = None;
if let Some(folder) = answer {
self.bind(folder, &ctx);
}
}
}
if let Some(run) = &mut self.run {
match run.drain() {
Update::Nothing | Update::Rows | Update::Finished => {}
}
}
egui::Panel::top("folder").show(ui, |ui| self.folder_bar(ui, &ctx));
egui::Panel::top("query").show(ui, |ui| self.query_bar(ui, &ctx));
egui::Panel::bottom("status").show(ui, |ui| self.status_bar(ui));
egui::Panel::right("detail")
.resizable(true)
.default_size(480.0)
.show(ui, |ui| self.detail_pane(ui));
egui::CentralPanel::default().show(ui, |ui| self.results(ui));
}
}
impl App {
fn folder_bar(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) {
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.button(t("Open folder…")).clicked() {
self.pick_folder();
}
let recursive = ui
.checkbox(&mut self.recursive, t("recursive"))
.on_hover_text(t("Descend into subdirectories."));
if recursive.changed() {
self.run_query(ctx);
}
match &self.folder {
Some(folder) => {
ui.label(folder.display().to_string());
}
None => {
ui.weak(t("No folder chosen."));
}
}
});
ui.add_space(4.0);
}
fn query_bar(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) {
ui.add_space(4.0);
ui.horizontal(|ui| {
let run = ui.button(t("Run"));
let bound = self.folder.is_some();
let box_ = ui.add_enabled(
bound,
egui::TextEdit::singleline(&mut self.query)
.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace)
.hint_text(FIRST_QUERY),
);
let entered = box_.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if bound && (run.clicked() || entered) {
self.run_query(ctx);
box_.request_focus();
}
});
if let Some(refused) = &self.refused {
ui.horizontal_wrapped(|ui| {
ui.colored_label(ui.visuals().error_fg_color, refused);
});
}
ui.add_space(4.0);
}
fn status_bar(&mut self, ui: &mut egui::Ui) {
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
match &self.run {
Some(run) => ui.label(run.status()),
None => ui.weak(t("No query has been run.")),
};
});
if let Some(said) = &self.said {
ui.horizontal_wrapped(|ui| {
ui.colored_label(ui.visuals().error_fg_color, said);
});
}
if let Some(notices) = self.run.as_ref().and_then(Run::notices) {
if notices.lines() > 0 {
egui::CollapsingHeader::new(t("What the scan noticed"))
.id_salt("notices")
.show(ui, |ui| {
for line in notices.skipped.iter().chain(¬ices.mismatches) {
ui.weak(line);
}
});
}
}
ui.add_space(4.0);
}
fn results(&mut self, ui: &mut egui::Ui) {
let Some(run) = &self.run else {
ui.centered_and_justified(|ui| {
ui.weak(t("Choose a folder, then run a query."));
});
return;
};
if run.columns().is_empty() && run.rows().is_empty() {
ui.centered_and_justified(|ui| {
ui.weak(if run.is_running() {
t("Scanning…")
} else {
t("Nothing matched.")
});
});
return;
}
let mut chosen: Option<String> = None;
let selected = self.selected.as_ref().map(|d| d.relative.clone());
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
egui::Grid::new("results")
.striped(true)
.num_columns(run.columns().len())
.show(ui, |ui| {
for column in run.columns() {
ui.strong(column);
}
ui.end_row();
for row in run.rows() {
let is_selected = selected.as_deref() == Some(row.path.as_str());
for column in run.columns() {
if ui
.selectable_label(is_selected, query::cell(row, column))
.clicked()
{
chosen = Some(row.path.clone());
}
}
ui.end_row();
}
});
});
if let Some(relative) = chosen {
if let Some(folder) = self.folder.clone() {
self.said = None;
self.selected = Some(Detail::open(&folder, &relative));
}
}
}
fn detail_pane(&mut self, ui: &mut egui::Ui) {
ui.take_available_width();
let Some(detail) = &self.selected else {
ui.add_space(8.0);
ui.weak(t("Select a row."));
return;
};
ui.add_space(8.0);
ui.strong(detail.relative.clone());
ui.add_space(8.0);
let can_open = detail.content().is_some_and(filebase::detail::ContentFile::can_be_opened);
match &detail.outcome {
Outcome::Unreadable(why) => {
ui.colored_label(ui.visuals().error_fg_color, why);
return;
}
Outcome::Read(contents) => {
ui.label(t("Content file"));
ui.label(contents.content.name.clone());
ui.weak(contents.content.size_line());
if let Some(why) = &contents.content.unreadable {
ui.weak(fill(
t("This build cannot decode it: {why}"),
&[("why", why)],
));
}
}
}
ui.add_space(6.0);
if ui
.add_enabled(can_open, egui::Button::new(t("Open content file")))
.clicked()
{
self.open_content_file();
}
ui.add_space(8.0);
ui.separator();
ui.label(t("Flyleaf"));
let Some(detail) = &mut self.selected else {
return;
};
let Outcome::Read(contents) = &mut detail.outcome else {
return;
};
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
flyleaf::render(ui, &mut contents.flyleaf, &ReadOnly);
});
}
}
#[cfg(test)]
mod tests {
use super::CATALOGUES;
#[test]
fn german_is_one_of_the_catalogues() {
assert!(CATALOGUES.iter().any(|(tag, _)| *tag == "de"));
}
#[test]
fn the_german_catalogue_answers() {
assert_eq!(
filebase::i18n::set_language("de", CATALOGUES).as_deref(),
Some("de")
);
assert_eq!(filebase::i18n::t("Content file"), "Inhaltsdatei");
assert_eq!(filebase::i18n::t("Open content file"), "Inhaltsdatei öffnen");
assert!(filebase::i18n::t("{path} is not a folder.").contains("{path}"));
}
}