Initial Wayfinder release
This commit is contained in:
commit
e8513b943a
16 files changed
+8234
No files matched your search
@@ -0,0 +1,929 @@
|
||||
use gtk::gio;
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
use std::cell::Cell;
|
||||
use std::cell::RefCell;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Entry {
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
directory: bool,
|
||||
size: u64,
|
||||
modified: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Browser {
|
||||
location: Rc<RefCell<PathBuf>>,
|
||||
show_hidden: Rc<RefCell<bool>>,
|
||||
list: gtk::ListBox,
|
||||
location_entry: gtk::Entry,
|
||||
status: gtk::Label,
|
||||
filter: gtk::SearchEntry,
|
||||
preview_title: gtk::Label,
|
||||
preview_details: gtk::Label,
|
||||
preview_panel: gtk::Box,
|
||||
preview_stack: gtk::Stack,
|
||||
preview_text: gtk::TextView,
|
||||
preview_picture: gtk::Picture,
|
||||
preview_message: gtk::Label,
|
||||
preview_request: Rc<Cell<u64>>,
|
||||
preview_job: Rc<RefCell<Option<PreviewJob>>>,
|
||||
preview_file: Rc<RefCell<Option<PathBuf>>>,
|
||||
}
|
||||
|
||||
struct PreviewJob {
|
||||
request: u64,
|
||||
child: Child,
|
||||
output: PathBuf,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let app = gtk::Application::builder()
|
||||
.application_id("dev.wfalkowski.Wayfinder")
|
||||
.flags(gio::ApplicationFlags::NON_UNIQUE)
|
||||
.build();
|
||||
app.connect_activate(build_ui);
|
||||
app.run();
|
||||
}
|
||||
|
||||
fn build_ui(app: >k::Application) {
|
||||
install_style();
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/"));
|
||||
let window = gtk::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("Wayfinder")
|
||||
.default_width(1080)
|
||||
.default_height(700)
|
||||
.build();
|
||||
|
||||
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
root.add_css_class("root");
|
||||
let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
toolbar.add_css_class("toolbar");
|
||||
|
||||
let back = tool_button("", "Go to parent folder");
|
||||
let back_icon = gtk::Image::from_icon_name("go-previous");
|
||||
back_icon.set_pixel_size(16);
|
||||
back_icon.set_halign(gtk::Align::Center);
|
||||
back_icon.set_valign(gtk::Align::Center);
|
||||
back.set_child(Some(&back_icon));
|
||||
back.add_css_class("back-button");
|
||||
let refresh = tool_button("↻", "Reload this folder");
|
||||
let location_entry = gtk::Entry::builder()
|
||||
.hexpand(true)
|
||||
.placeholder_text("Enter a folder path")
|
||||
.build();
|
||||
location_entry.add_css_class("location");
|
||||
let filter = gtk::SearchEntry::builder()
|
||||
.placeholder_text("Filter")
|
||||
.width_chars(18)
|
||||
.build();
|
||||
toolbar.append(&back);
|
||||
toolbar.append(&refresh);
|
||||
toolbar.append(&location_entry);
|
||||
toolbar.append(&filter);
|
||||
root.append(&toolbar);
|
||||
|
||||
let body = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
body.set_vexpand(true);
|
||||
let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 2);
|
||||
sidebar.set_width_request(168);
|
||||
sidebar.set_hexpand(false);
|
||||
sidebar.add_css_class("sidebar");
|
||||
let places = [
|
||||
("Desktop", home.join("Desktop")),
|
||||
("Documents", home.join("Documents")),
|
||||
("Downloads", home.join("Downloads")),
|
||||
("Pictures", home.join("Pictures")),
|
||||
];
|
||||
for (label, path) in places {
|
||||
let button = gtk::Button::with_label(label);
|
||||
button.set_halign(gtk::Align::Fill);
|
||||
if let Some(label) = button
|
||||
.child()
|
||||
.and_then(|child| child.downcast::<gtk::Label>().ok())
|
||||
{
|
||||
label.set_xalign(0.0);
|
||||
label.set_hexpand(true);
|
||||
}
|
||||
button.add_css_class("place");
|
||||
// Place actions are wired after the browser exists below.
|
||||
button.set_tooltip_text(Some(&path.display().to_string()));
|
||||
button.set_widget_name(&format!("place:{}", path.display()));
|
||||
sidebar.append(&button);
|
||||
}
|
||||
body.append(&sidebar);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
content.set_hexpand(true);
|
||||
content.set_vexpand(true);
|
||||
let headings = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
headings.add_css_class("headings");
|
||||
headings.append(&heading("Name", true));
|
||||
headings.append(&heading("Size", false));
|
||||
headings.append(&heading("Modified", false));
|
||||
content.append(&headings);
|
||||
|
||||
let list = gtk::ListBox::new();
|
||||
list.set_selection_mode(gtk::SelectionMode::Single);
|
||||
list.set_activate_on_single_click(false);
|
||||
list.add_css_class("file-list");
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.child(&list)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
content.append(&scroll);
|
||||
|
||||
let preview_heading = gtk::Label::new(Some("Preview"));
|
||||
preview_heading.set_xalign(0.0);
|
||||
preview_heading.add_css_class("preview-heading");
|
||||
let preview_title = gtk::Label::new(Some("Select a file"));
|
||||
preview_title.set_xalign(0.0);
|
||||
preview_title.set_ellipsize(gtk::pango::EllipsizeMode::Middle);
|
||||
preview_title.add_css_class("preview-title");
|
||||
let preview_details = gtk::Label::new(Some("Select a file to preview"));
|
||||
preview_details.set_xalign(0.0);
|
||||
preview_details.set_wrap(true);
|
||||
preview_details.add_css_class("preview-details");
|
||||
|
||||
let preview_stack = gtk::Stack::new();
|
||||
preview_stack.set_hexpand(true);
|
||||
preview_stack.set_vexpand(true);
|
||||
preview_stack.set_visible_child_name("empty");
|
||||
let preview_empty = gtk::Label::new(Some("Select a file to preview"));
|
||||
preview_empty.set_wrap(true);
|
||||
preview_empty.add_css_class("preview-message");
|
||||
preview_stack.add_named(&preview_empty, Some("empty"));
|
||||
|
||||
let preview_text = gtk::TextView::new();
|
||||
preview_text.set_editable(false);
|
||||
preview_text.set_cursor_visible(false);
|
||||
preview_text.set_monospace(true);
|
||||
preview_text.set_wrap_mode(gtk::WrapMode::None);
|
||||
preview_text.add_css_class("preview-text");
|
||||
let text_scroll = gtk::ScrolledWindow::builder()
|
||||
.child(&preview_text)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_stack.add_named(&text_scroll, Some("text"));
|
||||
|
||||
let preview_picture = gtk::Picture::new();
|
||||
preview_picture.set_can_shrink(true);
|
||||
preview_picture.set_keep_aspect_ratio(true);
|
||||
preview_picture.set_halign(gtk::Align::Center);
|
||||
preview_picture.set_valign(gtk::Align::Center);
|
||||
preview_picture.set_hexpand(true);
|
||||
preview_picture.set_vexpand(true);
|
||||
preview_picture.add_css_class("preview-picture");
|
||||
preview_stack.add_named(&preview_picture, Some("image"));
|
||||
|
||||
let preview_message = gtk::Label::new(None);
|
||||
preview_message.set_wrap(true);
|
||||
preview_message.set_justify(gtk::Justification::Center);
|
||||
preview_message.add_css_class("preview-message");
|
||||
preview_stack.add_named(&preview_message, Some("message"));
|
||||
|
||||
let preview = gtk::Box::new(gtk::Orientation::Vertical, 10);
|
||||
preview.set_width_request(320);
|
||||
preview.set_hexpand(false);
|
||||
preview.set_vexpand(true);
|
||||
preview.set_visible(false);
|
||||
preview.add_css_class("preview-pane");
|
||||
preview.append(&preview_heading);
|
||||
preview.append(&preview_title);
|
||||
preview.append(&preview_details);
|
||||
preview.append(&preview_stack);
|
||||
|
||||
let workspace = gtk::Paned::new(gtk::Orientation::Horizontal);
|
||||
workspace.set_start_child(Some(&content));
|
||||
workspace.set_end_child(Some(&preview));
|
||||
workspace.set_position(620);
|
||||
workspace.set_hexpand(true);
|
||||
workspace.set_vexpand(true);
|
||||
workspace.set_resize_start_child(true);
|
||||
workspace.set_resize_end_child(false);
|
||||
workspace.set_shrink_start_child(false);
|
||||
workspace.set_shrink_end_child(false);
|
||||
workspace.add_css_class("workspace-split");
|
||||
body.append(&workspace);
|
||||
root.append(&body);
|
||||
|
||||
let status = gtk::Label::new(None);
|
||||
status.set_xalign(0.0);
|
||||
status.add_css_class("status");
|
||||
status.set_margin_start(12);
|
||||
status.set_margin_end(12);
|
||||
status.set_margin_top(6);
|
||||
status.set_margin_bottom(6);
|
||||
root.append(&status);
|
||||
window.set_child(Some(&root));
|
||||
|
||||
let browser = Browser {
|
||||
location: Rc::new(RefCell::new(home)),
|
||||
show_hidden: Rc::new(RefCell::new(false)),
|
||||
list: list.clone(),
|
||||
location_entry: location_entry.clone(),
|
||||
status: status.clone(),
|
||||
filter: filter.clone(),
|
||||
preview_title,
|
||||
preview_details,
|
||||
preview_panel: preview,
|
||||
preview_stack,
|
||||
preview_text,
|
||||
preview_picture,
|
||||
preview_message,
|
||||
preview_request: Rc::new(Cell::new(0)),
|
||||
preview_job: Rc::new(RefCell::new(None)),
|
||||
preview_file: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
reload(&browser);
|
||||
|
||||
{
|
||||
let b = browser.clone();
|
||||
location_entry.connect_activate(move |entry| {
|
||||
let path = PathBuf::from(entry.text().as_str());
|
||||
navigate(&b, path);
|
||||
});
|
||||
}
|
||||
{
|
||||
let b = browser.clone();
|
||||
back.connect_clicked(move |_| {
|
||||
let parent = b.location.borrow().parent().map(Path::to_path_buf);
|
||||
if let Some(path) = parent {
|
||||
navigate(&b, path);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let b = browser.clone();
|
||||
refresh.connect_clicked(move |_| reload(&b));
|
||||
}
|
||||
{
|
||||
let b = browser.clone();
|
||||
filter.connect_search_changed(move |_| apply_filter(&b));
|
||||
}
|
||||
{
|
||||
let b = browser.clone();
|
||||
list.connect_row_selected(move |_, row| {
|
||||
if let Some(path) = row
|
||||
.and_then(|row| row.tooltip_text())
|
||||
.map(|path| PathBuf::from(path.as_str()))
|
||||
{
|
||||
show_preview(&b, path);
|
||||
} else {
|
||||
clear_preview(&b);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let b = browser.clone();
|
||||
list.connect_row_activated(move |_, row| {
|
||||
if let Some(path) = row.tooltip_text() {
|
||||
navigate(&b, PathBuf::from(path.as_str()));
|
||||
}
|
||||
});
|
||||
}
|
||||
// Sidebar uses the path stored on each button; this avoids hard-coded home assumptions.
|
||||
let mut child = sidebar.first_child();
|
||||
while let Some(widget) = child {
|
||||
child = widget.next_sibling();
|
||||
if let Ok(button) = widget.clone().downcast::<gtk::Button>() {
|
||||
let b = browser.clone();
|
||||
let path = button
|
||||
.widget_name()
|
||||
.strip_prefix("place:")
|
||||
.map(PathBuf::from);
|
||||
if let Some(path) = path {
|
||||
button.connect_clicked(move |_| navigate(&b, path.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let b = browser.clone();
|
||||
let keys = gtk::EventControllerKey::new();
|
||||
keys.connect_key_pressed(move |_, key, _, state| {
|
||||
if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::l {
|
||||
b.location_entry.grab_focus();
|
||||
b.location_entry.select_region(0, -1);
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
if key == gtk::gdk::Key::F5 {
|
||||
reload(&b);
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::h {
|
||||
let visible = !*b.show_hidden.borrow();
|
||||
*b.show_hidden.borrow_mut() = visible;
|
||||
reload(&b);
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
if key == gtk::gdk::Key::BackSpace {
|
||||
if let Some(parent) = b.location.borrow().parent().map(Path::to_path_buf) {
|
||||
navigate(&b, parent);
|
||||
}
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
window.add_controller(keys);
|
||||
}
|
||||
{
|
||||
let b = browser.clone();
|
||||
window.connect_close_request(move |_| {
|
||||
clear_preview(&b);
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
}
|
||||
window.present();
|
||||
}
|
||||
|
||||
fn navigate(browser: &Browser, path: PathBuf) {
|
||||
if path.is_dir() {
|
||||
*browser.location.borrow_mut() = path;
|
||||
reload(browser);
|
||||
} else if path.is_file() {
|
||||
match open_file(&path) {
|
||||
Ok(()) => browser.status.set_text(&format!(
|
||||
"Opened {}",
|
||||
path.file_name().unwrap_or_default().to_string_lossy()
|
||||
)),
|
||||
Err(error) => browser
|
||||
.status
|
||||
.set_text(&format!("Cannot open {}: {error}", path.display())),
|
||||
}
|
||||
} else {
|
||||
browser
|
||||
.status
|
||||
.set_text("That path does not exist or cannot be opened.");
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_preview(browser: &Browser) {
|
||||
cancel_preview_work(browser);
|
||||
browser.preview_panel.set_visible(false);
|
||||
browser.preview_title.set_text("Select a file");
|
||||
browser.preview_details.set_text("Select a file to preview");
|
||||
browser.preview_stack.set_visible_child_name("empty");
|
||||
}
|
||||
|
||||
fn cancel_preview_work(browser: &Browser) -> u64 {
|
||||
let request = browser.preview_request.get().wrapping_add(1);
|
||||
browser.preview_request.set(request);
|
||||
if let Some(mut job) = browser.preview_job.borrow_mut().take() {
|
||||
let _ = job.child.kill();
|
||||
let _ = job.child.wait();
|
||||
let _ = fs::remove_file(job.output);
|
||||
}
|
||||
browser.preview_picture.set_filename(None::<&Path>);
|
||||
if let Some(path) = browser.preview_file.borrow_mut().take() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
fn show_preview(browser: &Browser, path: PathBuf) {
|
||||
if !supports_preview(&path) {
|
||||
clear_preview(browser);
|
||||
return;
|
||||
}
|
||||
let request = cancel_preview_work(browser);
|
||||
browser.preview_panel.set_visible(true);
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
browser.preview_title.set_text(&name);
|
||||
|
||||
let details = fs::metadata(&path)
|
||||
.map(|metadata| {
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.map(format_time)
|
||||
.unwrap_or_else(|| "Unknown date".into());
|
||||
format!("{} · {modified}", format_size(metadata.len()))
|
||||
})
|
||||
.unwrap_or_else(|_| path.display().to_string());
|
||||
browser.preview_details.set_text(&details);
|
||||
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if is_preview_text(&extension) {
|
||||
show_text_preview(browser, &path);
|
||||
} else if is_preview_image(&extension) {
|
||||
start_image_preview(browser, path, request, details);
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_preview(path: &Path) -> bool {
|
||||
if path.is_dir() {
|
||||
return false;
|
||||
}
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
is_preview_text(&extension) || is_preview_image(&extension)
|
||||
}
|
||||
|
||||
fn set_preview_message(browser: &Browser, message: &str) {
|
||||
browser.preview_message.set_text(message);
|
||||
browser.preview_stack.set_visible_child_name("message");
|
||||
}
|
||||
|
||||
fn is_preview_text(extension: &str) -> bool {
|
||||
matches!(
|
||||
extension,
|
||||
"txt"
|
||||
| "md"
|
||||
| "markdown"
|
||||
| "rs"
|
||||
| "toml"
|
||||
| "json"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "ini"
|
||||
| "conf"
|
||||
| "log"
|
||||
| "sh"
|
||||
| "bash"
|
||||
| "zsh"
|
||||
| "fish"
|
||||
| "c"
|
||||
| "h"
|
||||
| "cc"
|
||||
| "cpp"
|
||||
| "hpp"
|
||||
| "py"
|
||||
| "html"
|
||||
| "css"
|
||||
| "js"
|
||||
| "ts"
|
||||
| "xml"
|
||||
| "csv"
|
||||
| "lock"
|
||||
| "desktop"
|
||||
| "service"
|
||||
| "lua"
|
||||
| "nix"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_preview_image(extension: &str) -> bool {
|
||||
matches!(
|
||||
extension,
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "svg" | "avif" | "ico"
|
||||
)
|
||||
}
|
||||
|
||||
fn show_text_preview(browser: &Browser, path: &Path) {
|
||||
const PREVIEW_LIMIT: usize = 64 * 1024;
|
||||
let mut bytes = Vec::with_capacity(PREVIEW_LIMIT + 1);
|
||||
let result = fs::File::open(path).and_then(|file| {
|
||||
file.take((PREVIEW_LIMIT + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
});
|
||||
if let Err(error) = result {
|
||||
set_preview_message(browser, &format!("Cannot read this file: {error}"));
|
||||
return;
|
||||
}
|
||||
if bytes.contains(&0) {
|
||||
set_preview_message(
|
||||
browser,
|
||||
"This file contains binary data and cannot be previewed as text.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let truncated = bytes.len() > PREVIEW_LIMIT;
|
||||
bytes.truncate(PREVIEW_LIMIT);
|
||||
let mut text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
if truncated {
|
||||
text.push_str("\n\n[Preview limited to the first 64 KiB]");
|
||||
}
|
||||
browser.preview_text.buffer().set_text(&text);
|
||||
browser.preview_stack.set_visible_child_name("text");
|
||||
}
|
||||
|
||||
fn start_image_preview(browser: &Browser, path: PathBuf, request: u64, details: String) {
|
||||
let source = match fs::canonicalize(&path) {
|
||||
Ok(source) => source,
|
||||
Err(error) => {
|
||||
set_preview_message(browser, &format!("Cannot open this image: {error}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let output_dir = std::env::temp_dir().join(format!("wayfinder-preview-{}", std::process::id()));
|
||||
if let Err(error) = fs::create_dir_all(&output_dir) {
|
||||
set_preview_message(browser, &format!("Cannot create preview cache: {error}"));
|
||||
return;
|
||||
}
|
||||
if let Err(error) = fs::set_permissions(&output_dir, fs::Permissions::from_mode(0o700)) {
|
||||
set_preview_message(browser, &format!("Cannot secure preview cache: {error}"));
|
||||
return;
|
||||
}
|
||||
let output = output_dir.join(format!("preview-{request}.png"));
|
||||
let _ = fs::remove_file(&output);
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default();
|
||||
let sandbox_input = format!("/wayfinder-input/preview.{extension}");
|
||||
let mut command = Command::new("bwrap");
|
||||
command
|
||||
.args([
|
||||
"--unshare-user",
|
||||
"--unshare-pid",
|
||||
"--unshare-ipc",
|
||||
"--unshare-uts",
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--ro-bind",
|
||||
"/usr",
|
||||
"/usr",
|
||||
"--ro-bind",
|
||||
"/etc",
|
||||
"/etc",
|
||||
"--ro-bind-try",
|
||||
"/lib",
|
||||
"/lib",
|
||||
"--ro-bind-try",
|
||||
"/lib64",
|
||||
"/lib64",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--tmpfs",
|
||||
"/tmp",
|
||||
"--dir",
|
||||
"/wayfinder-input",
|
||||
"--ro-bind",
|
||||
])
|
||||
.arg(&source)
|
||||
.arg(&sandbox_input)
|
||||
.arg("--bind")
|
||||
.arg(&output_dir)
|
||||
.args([
|
||||
"/wayfinder-output",
|
||||
"--chdir",
|
||||
"/",
|
||||
"--setenv",
|
||||
"HOME",
|
||||
"/tmp",
|
||||
"--",
|
||||
"/usr/bin/gdk-pixbuf-thumbnailer",
|
||||
"--size",
|
||||
"512",
|
||||
])
|
||||
.arg(&sandbox_input)
|
||||
.arg(format!("/wayfinder-output/preview-{request}.png"));
|
||||
command.stdout(std::process::Stdio::null());
|
||||
command.stderr(std::process::Stdio::null());
|
||||
|
||||
match command.spawn() {
|
||||
Ok(child) => {
|
||||
browser
|
||||
.preview_details
|
||||
.set_text("Generating image preview…");
|
||||
*browser.preview_job.borrow_mut() = Some(PreviewJob {
|
||||
request,
|
||||
child,
|
||||
output: output.clone(),
|
||||
started: Instant::now(),
|
||||
});
|
||||
let browser = browser.clone();
|
||||
glib::timeout_add_local(Duration::from_millis(80), move || {
|
||||
if browser.preview_request.get() != request {
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
let completion = {
|
||||
let mut pending = browser.preview_job.borrow_mut();
|
||||
let Some(job) = pending.as_mut() else {
|
||||
return glib::ControlFlow::Break;
|
||||
};
|
||||
if job.request != request {
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
match job.child.try_wait() {
|
||||
Ok(None) if job.started.elapsed() < Duration::from_secs(8) => None,
|
||||
Ok(None) => {
|
||||
let _ = job.child.kill();
|
||||
let _ = job.child.wait();
|
||||
Some(false)
|
||||
}
|
||||
Ok(Some(status)) => Some(status.success()),
|
||||
Err(_) => Some(false),
|
||||
}
|
||||
.map(|success| {
|
||||
let job = pending.take().expect("preview job exists");
|
||||
(success, job.output)
|
||||
})
|
||||
};
|
||||
let Some((success, output)) = completion else {
|
||||
return glib::ControlFlow::Continue;
|
||||
};
|
||||
if success && output.is_file() {
|
||||
browser.preview_picture.set_filename(Some(&output));
|
||||
browser.preview_details.set_text(&details);
|
||||
*browser.preview_file.borrow_mut() = Some(output);
|
||||
browser.preview_stack.set_visible_child_name("image");
|
||||
} else {
|
||||
let _ = fs::remove_file(output);
|
||||
set_preview_message(
|
||||
&browser,
|
||||
"Image preview failed. The format may be unsupported, or the sandboxed thumbnailer may be unavailable.",
|
||||
);
|
||||
}
|
||||
glib::ControlFlow::Break
|
||||
});
|
||||
}
|
||||
Err(error) => set_preview_message(
|
||||
browser,
|
||||
&format!("Could not start the sandboxed image preview: {error}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_file(path: &Path) -> Result<(), String> {
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let text_extensions = [
|
||||
"txt", "md", "markdown", "rs", "toml", "json", "yaml", "yml", "ini", "conf", "log", "sh",
|
||||
"bash", "zsh", "fish", "c", "h", "cc", "cpp", "hpp", "py", "html", "css", "js", "ts",
|
||||
"xml", "csv", "lock", "desktop", "service", "lua", "nix",
|
||||
];
|
||||
let image_extensions = [
|
||||
"png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg", "avif",
|
||||
];
|
||||
|
||||
let mut command = if text_extensions.contains(&extension.as_str()) {
|
||||
let mut command = Command::new("foot");
|
||||
command.args(["-e", "nvim", "--"]).arg(path);
|
||||
command
|
||||
} else if image_extensions.contains(&extension.as_str()) {
|
||||
let mut command = Command::new("imv");
|
||||
command.arg(path);
|
||||
command
|
||||
} else if fs::metadata(path)
|
||||
.map(|metadata| metadata.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let mut command = Command::new("foot");
|
||||
command.arg("-e").arg(path);
|
||||
command
|
||||
} else {
|
||||
let uri = gio::File::for_path(path).uri();
|
||||
return gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>)
|
||||
.map_err(|error| error.to_string());
|
||||
};
|
||||
|
||||
command
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("Could not start the requested application: {error}"))
|
||||
}
|
||||
|
||||
fn reload(browser: &Browser) {
|
||||
clear_preview(browser);
|
||||
while let Some(row) = browser.list.first_child() {
|
||||
browser.list.remove(&row);
|
||||
}
|
||||
let path = browser.location.borrow().clone();
|
||||
browser.location_entry.set_text(&path.display().to_string());
|
||||
let entries = match fs::read_dir(&path) {
|
||||
Ok(items) => items,
|
||||
Err(error) => {
|
||||
browser
|
||||
.status
|
||||
.set_text(&format!("Cannot read {}: {error}", path.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
for item in entries.flatten() {
|
||||
let path = item.path();
|
||||
let name = item.file_name().to_string_lossy().into_owned();
|
||||
if !*browser.show_hidden.borrow() && name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = item.metadata() else { continue };
|
||||
let directory = meta.is_dir();
|
||||
let modified = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.map(format_time)
|
||||
.unwrap_or_else(|| "—".into());
|
||||
rows.push(Entry {
|
||||
path,
|
||||
name,
|
||||
directory,
|
||||
size: meta.len(),
|
||||
modified,
|
||||
});
|
||||
}
|
||||
rows.sort_by(|a, b| {
|
||||
b.directory
|
||||
.cmp(&a.directory)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
for entry in rows {
|
||||
let row = make_row(&entry);
|
||||
row.set_selectable(true);
|
||||
row.set_activatable(true);
|
||||
row.set_tooltip_text(Some(&entry.path.to_string_lossy()));
|
||||
browser.list.append(&row);
|
||||
}
|
||||
let count = browser.list.observe_children().n_items();
|
||||
browser
|
||||
.status
|
||||
.set_text(&format!("{count} items · {}", path.display()));
|
||||
apply_filter(browser);
|
||||
}
|
||||
|
||||
fn make_row(entry: &Entry) -> gtk::ListBoxRow {
|
||||
let row = gtk::ListBoxRow::new();
|
||||
let line = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
line.set_margin_start(8);
|
||||
line.set_margin_end(8);
|
||||
line.set_margin_top(5);
|
||||
line.set_margin_bottom(5);
|
||||
let name = gtk::Label::new(None);
|
||||
let display_name: String = entry
|
||||
.name
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_control() {
|
||||
'�'
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
name.set_text(&if entry.directory {
|
||||
format!("{display_name}/")
|
||||
} else {
|
||||
display_name
|
||||
});
|
||||
name.set_xalign(0.0);
|
||||
name.set_wrap(true);
|
||||
name.set_wrap_mode(gtk::pango::WrapMode::WordChar);
|
||||
name.set_valign(gtk::Align::Center);
|
||||
name.set_hexpand(true);
|
||||
name.add_css_class(if entry.directory {
|
||||
"directory"
|
||||
} else {
|
||||
"filename"
|
||||
});
|
||||
let size_text = if entry.directory {
|
||||
"Folder".to_string()
|
||||
} else {
|
||||
format_size(entry.size)
|
||||
};
|
||||
let size = metadata_value(&size_text, 72);
|
||||
let modified = metadata_value(&entry.modified, 126);
|
||||
line.append(&name);
|
||||
line.append(&size);
|
||||
line.append(&modified);
|
||||
row.set_child(Some(&line));
|
||||
row
|
||||
}
|
||||
|
||||
fn heading(text: &str, expand: bool) -> gtk::Label {
|
||||
let label = gtk::Label::new(Some(text));
|
||||
label.set_xalign(0.0);
|
||||
if expand {
|
||||
label.set_hexpand(true);
|
||||
} else {
|
||||
label.set_size_request(if text == "Size" { 72 } else { 126 }, -1);
|
||||
}
|
||||
label.add_css_class("column-heading");
|
||||
label
|
||||
}
|
||||
|
||||
fn metadata_value(text: &str, width: i32) -> gtk::Label {
|
||||
let value = gtk::Label::new(Some(text));
|
||||
value.set_size_request(width, 24);
|
||||
value.set_xalign(0.0);
|
||||
value.set_valign(gtk::Align::Center);
|
||||
value.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
||||
value.add_css_class("metadata");
|
||||
value
|
||||
}
|
||||
|
||||
fn tool_button(label: &str, tip: &str) -> gtk::Button {
|
||||
let button = gtk::Button::with_label(label);
|
||||
button.set_tooltip_text(Some(tip));
|
||||
button.set_size_request(34, 34);
|
||||
button.add_css_class("tool-button");
|
||||
button
|
||||
}
|
||||
|
||||
fn apply_filter(browser: &Browser) {
|
||||
let needle = browser.filter.text().to_lowercase();
|
||||
let mut child = browser.list.first_child();
|
||||
while let Some(widget) = child {
|
||||
child = widget.next_sibling();
|
||||
if let Ok(row) = widget.clone().downcast::<gtk::ListBoxRow>() {
|
||||
let name = row
|
||||
.child()
|
||||
.and_then(|line| line.first_child())
|
||||
.and_then(|label| label.downcast::<gtk::Label>().ok())
|
||||
.map(|label| label.text().to_string().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
row.set_visible(name.contains(&needle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{bytes} B")
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
fn format_time(time: std::time::SystemTime) -> String {
|
||||
// Avoid adding a date/time dependency; present a stable age hint when local formatting is unavailable.
|
||||
match time.elapsed() {
|
||||
Ok(age) if age.as_secs() < 60 => "Just now".into(),
|
||||
Ok(age) if age.as_secs() < 3600 => format!("{} min ago", age.as_secs() / 60),
|
||||
Ok(age) if age.as_secs() < 86400 => format!("{} hr ago", age.as_secs() / 3600),
|
||||
Ok(age) => format!("{} days ago", age.as_secs() / 86400),
|
||||
Err(_) => "Just now".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_style() {
|
||||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_data(r#"
|
||||
* { font-family: "Maple Mono", "JetBrainsMono Nerd Font", monospace; font-size: 10pt; }
|
||||
window, .root { background: #151515; color: #D0D0D0; }
|
||||
.toolbar { background: #181818; padding: 8px 10px; }
|
||||
entry, searchentry { color: #D0D0D0; background: #202020; border: 1px solid #2F2F2F; border-radius: 2px; padding: 7px 9px; min-height: 18px; }
|
||||
entry:focus, searchentry:focus { border-color: #6C99BB; }
|
||||
.tool-button { color: #D0D0D0; background: #202020; border: 1px solid #2F2F2F; border-radius: 2px; padding: 0; min-width: 34px; min-height: 34px; }
|
||||
.back-button { min-width: 34px; min-height: 34px; }
|
||||
.tool-button:hover { color: #F5F5F5; background: #303030; border-color: #454545; }
|
||||
.tool-button:focus-visible, .place:focus-visible, row:focus-visible { outline: 1px solid #6C99BB; outline-offset: -2px; }
|
||||
.sidebar { background: #181818; border-top: 2px solid #2F2F2F; border-right: 1px solid #2F2F2F; padding: 8px 6px; min-width: 168px; max-width: 168px; }
|
||||
.place { color: #D0D0D0; background: transparent; border: 1px solid transparent; border-radius: 2px; padding: 7px 9px; min-height: 20px; }
|
||||
.place:hover { color: #F5F5F5; background: #202020; border-color: #2F2F2F; }
|
||||
.place:active { background: #303030; }
|
||||
.headings { background: #181818; border-top: 2px solid #2F2F2F; border-bottom: 1px solid #2F2F2F; padding: 9px 8px; }
|
||||
.preview-pane { background: #181818; border-left: 1px solid #2F2F2F; padding: 12px; min-width: 320px; max-width: 320px; }
|
||||
.preview-heading { color: #F5F5F5; font-size: 11pt; }
|
||||
.preview-title { color: #D0D0D0; font-size: 10pt; }
|
||||
.preview-details { color: #757575; font-size: 9pt; }
|
||||
.preview-message { color: #858585; padding: 12px; }
|
||||
.preview-text { background: #151515; color: #D0D0D0; padding: 10px; }
|
||||
.preview-picture { background: #151515; }
|
||||
.workspace-split > separator { background: #2F2F2F; min-width: 1px; }
|
||||
.column-heading { color: #757575; font-size: 9pt; }
|
||||
.file-list { background: #151515; }
|
||||
.file-list row { border-bottom: 1px solid #202020; min-height: 36px; }
|
||||
.file-list row:hover { background: #202020; }
|
||||
.file-list row:selected { background: #303030; outline: 1px solid #6C99BB; outline-offset: -1px; }
|
||||
.directory { color: #8DB3D0; }
|
||||
.filename { color: #D0D0D0; }
|
||||
.metadata, .status { color: #858585; font-size: 10pt; }
|
||||
.metadata { padding: 3px 0 5px; }
|
||||
scrolledwindow { border: 0; }
|
||||
scrollbar slider { background: #454545; min-width: 7px; min-height: 24px; border-radius: 2px; }
|
||||
"#);
|
||||
if let Some(display) = gtk::gdk::Display::default() {
|
||||
gtk::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user