930 lines
31 KiB
Plaintext
930 lines
31 KiB
Plaintext
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,
|
|
);
|
|
}
|
|
}
|