From d29268d8be3b6734983fdfa5cea6d4f0209b92b9 Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Wed, 18 Aug 2021 13:34:30 +0200 Subject: [PATCH] feat(files): Convert to Rust --- Cargo.lock | 14 +++ Cargo.toml | 2 +- Makefile | 2 +- bin/src/main.rs | 1 + debian/pop-launcher.links | 1 + plugins/Cargo.toml | 2 + plugins/src/files/files.js | 225 ----------------------------------- plugins/src/files/mod.rs | 172 ++++++++++++++++++++++++++ plugins/src/files/plugin.ron | 2 +- plugins/src/lib.rs | 1 + 10 files changed, 194 insertions(+), 228 deletions(-) delete mode 100755 plugins/src/files/files.js create mode 100644 plugins/src/files/mod.rs diff --git a/Cargo.lock b/Cargo.lock index fa32698..901ef9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -566,6 +566,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "human-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140a09c9305e6d5e557e2ed7cbc68e05765a7d4213975b87cb04920689cc6219" + +[[package]] +name = "human_format" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86cce260d758a9aa3d7c4b99d55c815a540f8a37514ba6046ab6be402a157cb0" + [[package]] name = "ident_case" version = "1.0.1" @@ -796,6 +808,8 @@ dependencies = [ "freedesktop-desktop-entry", "futures-lite", "futures_codec", + "human-sort", + "human_format", "new_mime_guess", "pop-launcher", "postage", diff --git a/Cargo.toml b/Cargo.toml index c5f27fe..06149c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,4 +16,4 @@ futures_codec = "0.4" futures-lite = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_with = "1" +serde_with = "1" \ No newline at end of file diff --git a/Makefile b/Makefile index cabc671..4a9e0ea 100644 --- a/Makefile +++ b/Makefile @@ -78,7 +78,7 @@ install: ln -sf $(BIN) $(PLUGIN_DIR)/calc/calc # Files plugin - install -Dm0755 plugins/src/files/files.js $(PLUGIN_DIR)/files + ln -sf $(BIN) $(PLUGIN_DIR)/files/files # Recent plugin install -Dm0755 plugins/src/recent/recent.js $(PLUGIN_DIR)/recent diff --git a/bin/src/main.rs b/bin/src/main.rs index cdc41f2..b21abc4 100644 --- a/bin/src/main.rs +++ b/bin/src/main.rs @@ -18,6 +18,7 @@ fn main() { "calc" => block_on(plugins::calc::main()), "desktop-entries" => block_on(plugins::desktop_entries::main()), "find" => block_on(plugins::find::main()), + "files" => block_on(plugins::files::main()), "pop-launcher" => block_on(service::main()), "pop-shell" => block_on(plugins::pop_shell::main()), "scripts" => block_on(plugins::scripts::main()), diff --git a/debian/pop-launcher.links b/debian/pop-launcher.links index fa23a8a..c18b6fc 100644 --- a/debian/pop-launcher.links +++ b/debian/pop-launcher.links @@ -1,5 +1,6 @@ /usr/bin/pop-launcher /usr/lib/pop-launcher/plugins/calc/calc /usr/bin/pop-launcher /usr/lib/pop-launcher/plugins/desktop_entries/desktop-entries +/usr/bin/pop-launcher /usr/lib/pop-launcher/plugins/files/files /usr/bin/pop-launcher /usr/lib/pop-launcher/plugins/find/find /usr/bin/pop-launcher /usr/lib/pop-launcher/plugins/pop_shell/pop-shell /usr/bin/pop-launcher /usr/lib/pop-launcher/plugins/scripts/scripts diff --git a/plugins/Cargo.toml b/plugins/Cargo.toml index 8aab4df..45b0282 100644 --- a/plugins/Cargo.toml +++ b/plugins/Cargo.toml @@ -25,3 +25,5 @@ tracing-subscriber = "0.2" urlencoding = "2" zbus = "1" zvariant = "=2.6" # Restrict for 1.47 +human-sort = "0.2.2" +human_format = "1.0.3" diff --git a/plugins/src/files/files.js b/plugins/src/files/files.js deleted file mode 100755 index e98c4b1..0000000 --- a/plugins/src/files/files.js +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/gjs - -const { GLib, Gio } = imports.gi; - -const STDIN = new Gio.DataInputStream({ base_stream: new Gio.UnixInputStream({ fd: 0 }) }) -const STDOUT = new Gio.DataOutputStream({ base_stream: new Gio.UnixOutputStream({ fd: 1 }) }) - -class App { - constructor() { - /** @type Array */ - this.selections = new Array() - - /** @type string */ - this.parent = "" - - /** @type string */ - this.last_query = "" - } - - /** - * Performs tab completion based on the last-given search query. - * - * @param {number} id - */ - complete(id) { - let text - - const selected = this.selections[id] - if (selected) { - text = selection_path(this.parent, selected) - } else { - text = this.last_query - } - - this.send({ "Fill": text }) - } - - /** - * Queries the plugin for results from this input - * - * @param {string} input - */ - search(input) { - if (input.startsWith('~')) { - input = GLib.get_home_dir() + input.substr(1) - } - - this.last_query = input - - // Add `/` to query if the input is a directory - this.last_query = (!input.endsWith('/') && Gio.file_new_for_path(input).query_file_type(0, null) === 2) - ? input + '/' - : input - - this.selections.splice(0) - this.parent = GLib.path_get_dirname(this.last_query) - - /** @type string */ - let base = GLib.path_get_basename(this.last_query) - - const show_hidden = base.startsWith('.') - - if (this.parent.endsWith(base)) base = "" - - try { - const dir = Gio.file_new_for_path(this.parent) - if (dir.query_exists(null)) { - const entries = dir.enumerate_children('standard::*', Gio.FileQueryInfoFlags.NONE, null); - let entry; - - while ((entry = entries.next_file(null)) !== null) { - /** @type {string} */ - const name = entry.get_name() - - if (base.length !== 0 && name.toLowerCase().indexOf(base.toLowerCase()) === -1) { - continue - } - - if (!show_hidden && name.startsWith('.')) continue - - const content_type = entry.get_content_type() - const directory = entry.get_file_type() === 2 - - this.selections.push({ - id: 0, - name, - description: GLib.format_size_for_display(entry.get_size()), - content_type, - directory - }) - - if (this.selections.length === 20) break - } - } - - const pattern_lower = this.last_query.toLowerCase() - - this.selections - .sort((a, b) => { - const a_name = a.name.toLowerCase() - const b_name = b.name.toLowerCase() - - const a_includes = a_name.includes(pattern_lower) - const b_includes = b_name.includes(pattern_lower) - - return ((a_includes && b_includes) || (!a_includes && !b_includes)) ? (a_name > b_name ? 1 : 0) : a_includes ? -1 : b_includes ? 1 : 0; - }) - - let id = 0 - for (const v of this.selections) { - v.id = id - id += 1 - } - } catch (e) { - log(`QUERY ERROR: ${e} `) - } - - for (const selection of this.selections) { - this.send({ "Append": { - id: selection.id, - name: selection.name, - description: selection.description, - icon: { "Mime": selection.content_type } - }}) - } - - this.send("Finished") - } - - /** - * Applies an option that the user selected - * - * @param {number} id - */ - activate(id) { - const selected = this.selections[id] - - if (selected) { - const path = selection_path(this.parent, selected) - try { - GLib.spawn_command_line_async(`xdg-open '${path}'`) - } catch (e) { - log(`xdg-open failed: ${e} `) - } - } - - this.send("Close") - } - - /** - * Sends message back to Pop Shell - * - * @param {Object} object - */ - send(object) { - STDOUT.write_bytes(new GLib.Bytes(JSON.stringify(object) + "\n"), null) - } -} - -/** - * - * @param {string} parent - * @param {Selection} selection - * @returns {string} - */ -function selection_path(parent, selection) { - let text = parent - + (parent.endsWith("/") ? "" : "/") - + selection.name - - if (selection.directory) text += "/" - - return text -} - -function main() { - /** @type {null | ByteArray} */ - let input_array - - /** @type {string} */ - let input_str - - /** @type {null | LauncherRequest} */ - let event - - let app = new App() - - mainloop: - while (true) { - try { - [input_array,] = STDIN.read_line(null) - } catch (e) { - break - } - - input_str = imports.byteArray.toString(input_array) - if ((event = parse_event(input_str)) !== null) { - if ("Search" in event) { - app.search(event.Search) - } else if ("Activate" in event) { - app.activate(event.Activate) - } else if ("Complete" in event) { - app.complete(event.Complete) - } else if ("Exit" === event) { - break mainloop - } - } - } -} - -/** - * Parses an IPC event received from STDIN - * @param {string} input - * @returns {null | LauncherRequest} - */ -function parse_event(input) { - try { - return JSON.parse(input) - } catch (e) { - log(`Input not valid JSON`) - return null - } -} - -main() diff --git a/plugins/src/files/mod.rs b/plugins/src/files/mod.rs new file mode 100644 index 0000000..137a36e --- /dev/null +++ b/plugins/src/files/mod.rs @@ -0,0 +1,172 @@ +use futures_lite::prelude::*; +use pop_launcher::*; +use smol::Unblock; +use std::{borrow::Cow, io, path::PathBuf}; + +struct Item { + path: PathBuf, + name: String, + description: String, + icon: IconSource, +} + +pub async fn main() { + let mut requests = json_input_stream(async_stdin()); + + let mut app = App::default(); + + while let Some(result) = requests.next().await { + match result { + Ok(request) => match request { + Request::Activate(id) => app.activate(id).await, + Request::Complete(id) => app.complete(id).await, + Request::Search(query) => app.search(query).await, + Request::Exit => break, + _ => (), + }, + Err(why) => { + tracing::error!("malformed JSON input: {}", why); + } + } + } +} + +pub struct App { + out: Unblock, + search_results: Vec, +} + +impl Default for App { + fn default() -> Self { + Self { + out: async_stdout(), + search_results: Vec::with_capacity(100), + } + } +} + +impl App { + pub async fn activate(&mut self, id: u32) { + if let Some(selected) = self.search_results.get(id as usize) { + crate::xdg_open(&selected.path); + crate::send(&mut self.out, PluginResponse::Close).await; + } + } + + pub async fn complete(&mut self, id: u32) { + if let Some(selected) = self.search_results.get(id as usize) { + if let Some(string) = selected.path.to_str() { + let fill = if selected.path.is_dir() { + [string, "/"].concat() + } else { + string.to_owned() + }; + + crate::send(&mut self.out, PluginResponse::Fill(fill)).await; + } + } + } + + pub async fn search(&mut self, query: String) { + let path = if let Some(stripped) = query.strip_prefix("~/") { + std::env::home_dir().expect("no home dir").join(stripped) + } else { + PathBuf::from(query) + }; + + let mut show_hidden = false; + let mut base = String::new(); + + if let Some(filename) = path.file_name().and_then(|s| s.to_str()) { + show_hidden = filename.starts_with('.'); + base = filename.to_ascii_lowercase(); + } + + self.search_results.clear(); + + let search_path = if path.is_dir() { + Some(path.as_path()) + } else if let Some(parent) = path.parent() { + Some(parent) + } else { + None + }; + + if let Some(parent) = search_path { + if let Ok(dir) = parent.read_dir() { + for entry in dir.filter_map(Result::ok) { + let path = entry.path(); + if let Some(name) = path.file_name().and_then(|x| x.to_str()) { + if !show_hidden && name.starts_with('.') { + continue; + } + + self.search_results.push(Item { + icon: IconSource::Mime(if path.is_dir() { + Cow::Borrowed("inode/directory") + } else if let Some(guess) = new_mime_guess::from_path(&path).first() { + Cow::Owned(guess.essence_str().to_owned()) + } else { + Cow::Borrowed("text/plain") + }), + name: name.to_owned(), + description: path + .metadata() + .ok() + .map(|meta| { + human_format::Formatter::new() + .with_scales(human_format::Scales::Binary()) + .with_units("B") + .format(meta.len() as f64) + }) + .unwrap_or_else(|| String::from("N/A")), + path, + }) + } + } + } + } + + use std::cmp::Ordering; + + self.search_results.sort_by(|a, b| { + let a_name = a.name.to_ascii_lowercase(); + let b_name = b.name.to_ascii_lowercase(); + + let a_contains = a_name.contains(&base); + let b_contains = b_name.contains(&base); + + if (a_contains && b_contains) || (!a_contains && !b_contains) { + if a_name.starts_with(&base) { + Ordering::Less + } else if b_name.starts_with(&base) { + Ordering::Greater + } else { + human_sort::compare(&a_name, &b_name) + } + } else if a_contains { + Ordering::Less + } else if b_contains { + Ordering::Equal + } else { + Ordering::Greater + } + }); + + for (id, selection) in self.search_results.iter().enumerate() { + crate::send( + &mut self.out, + PluginResponse::Append(PluginSearchResult { + id: id as u32, + name: selection.name.clone(), + description: selection.description.clone(), + icon: Some(selection.icon.clone()), + ..Default::default() + }), + ) + .await; + } + + crate::send(&mut self.out, PluginResponse::Finished).await; + } +} diff --git a/plugins/src/files/plugin.ron b/plugins/src/files/plugin.ron index 07f26bd..593b433 100644 --- a/plugins/src/files/plugin.ron +++ b/plugins/src/files/plugin.ron @@ -7,6 +7,6 @@ isolate: true, no_sort: true, ), - bin: (path: "files.js"), + bin: (path: "files"), icon: Name("system-file-manager") ) \ No newline at end of file diff --git a/plugins/src/lib.rs b/plugins/src/lib.rs index 7915dca..0f7339c 100644 --- a/plugins/src/lib.rs +++ b/plugins/src/lib.rs @@ -1,5 +1,6 @@ pub mod calc; pub mod desktop_entries; +pub mod files; pub mod find; pub mod pop_shell; pub mod scripts;