diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9b442d5..43ddb28 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,12 +1,120 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::time::{UNIX_EPOCH}; +use std::os::unix::fs::PermissionsExt; + // Learn more about Tauri commands at https://tauri.app/v1/guides/features/command #[tauri::command] fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) } +#[derive(Debug, serde::Serialize)] +struct EntryMetaData { + name: String, + path: String, + size: u64, + is_directory: bool, + is_file: bool, + is_symlink: bool, + directory_item_count: u64, + permission: String, + created: u64, + modified: u64, + accessed: u64, +} + +#[tauri::command] +fn get_files( + _window: tauri::Window, + directory: &str, +) -> Vec { + use std::fs; + use std::path::Path; + + let path = Path::new(directory); + + // Read the entries in the directory + let entries = fs::read_dir(path).unwrap(); + + let mut data: Vec = vec![]; + + for entry in entries { + let entry = entry.unwrap(); + let metadata = entry.metadata().unwrap(); + + let mode = metadata.permissions().mode(); + let permission = format!( + "{:04o} ({}{}{})", + mode, + if mode & 0o400 != 0 { 'r' } else { '-' }, + if mode & 0o200 != 0 { 'w' } else { '-' }, + if mode & 0o100 != 0 { 'x' } else { '-' }, + ); + + let mut directory_item_count = 0 as u64; + if metadata.is_dir() { + if let Ok(dir_entries) = fs::read_dir(entry.path()) { + directory_item_count = dir_entries.count() as u64; + } + } + + data.push(EntryMetaData{ + name: entry.file_name().to_string_lossy().to_string(), + path: entry.path().to_string_lossy().to_string(), + size: metadata.len(), + is_directory: metadata.is_dir(), + is_file: metadata.is_file(), + is_symlink: metadata.is_symlink(), + directory_item_count, + permission, + created: metadata.created().unwrap().duration_since(UNIX_EPOCH).unwrap().as_secs(), + modified: metadata.modified().unwrap().duration_since(UNIX_EPOCH).unwrap().as_secs(), + accessed: metadata.accessed().unwrap().duration_since(UNIX_EPOCH).unwrap().as_secs(), + }); + } + + return data; +} + +#[tauri::command] +fn open_file( + _window: tauri::Window, + path: &str, +) -> bool { + use std::process::Command; + use std::env; + + let os = env::consts::OS; + + match os { + "linux" => { + Command::new("xdg-open").arg(path).output() + .expect("Unable to open file"); + + return true; + }, + "macos" => { + Command::new("open").arg(path).output() + .expect("Unable to open file"); + + return true; + }, + "windows" => { + Command::new("cmd").arg("/C").arg("start").arg(path).output() + .expect("Unable to open file"); + + return true; + }, + _ => { + println!("Unsupported operating system: {}", os); + + return false; + } + } +} + fn main() { use tauri::Manager; tauri::Builder::default() @@ -18,7 +126,10 @@ fn main() { } Ok(()) }) - .invoke_handler(tauri::generate_handler![greet]) + .invoke_handler(tauri::generate_handler![ + get_files, + open_file + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 70c24e0..c94395a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,7 +19,19 @@ }, "fs": { "all": true, - "scope": ["**"] + "scope": [ + "**", + "$APP", + "$APP/**", + "$CACHE", + "$CACHE/**", + "$CONFIG", + "$CONFIG/**", + "$LOG", + "$LOG/**", + "$HOME/**", + "$HOME/.*" + ] }, "dialog": { "all": true diff --git a/src/index.html b/src/index.html index d8e1ede..4815a82 100644 --- a/src/index.html +++ b/src/index.html @@ -8,7 +8,7 @@ File Browser - +
@@ -53,5 +53,44 @@ + + + + + + + + + + diff --git a/src/index.js b/src/index.js index c4b8748..433adf3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,12 @@ import {renderFavourite} from "./parts/favourites.js"; import {renderBreadcrumbs} from "./parts/breadcrumbs.js"; +import {renderFiles} from "./parts/files.js"; async function Main() { renderBreadcrumbs(); renderFavourite(); + await renderFiles(); } Main(); diff --git a/src/libs/navigation.js b/src/libs/navigation.js index 5c0e011..ef15317 100644 --- a/src/libs/navigation.js +++ b/src/libs/navigation.js @@ -47,8 +47,4 @@ export const getCurrentDir = () => currentDir; const logDir = async () => console.log({ currentDir, items: await readDir(currentDir) -}); - -onNavigate(logDir); - -logDir(); \ No newline at end of file +}); \ No newline at end of file diff --git a/src/libs/template.js b/src/libs/template.js index 1ecfaee..9734558 100644 --- a/src/libs/template.js +++ b/src/libs/template.js @@ -1,5 +1,10 @@ import {getElementOrThrow, getOneElementOrThrow} from "./element.js"; +/** + * @typedef {Object} Template + * @property {function} el + * @property {function} render + */ /** * @param {string} name diff --git a/src/parts/files.js b/src/parts/files.js new file mode 100644 index 0000000..93054c7 --- /dev/null +++ b/src/parts/files.js @@ -0,0 +1,93 @@ +import {getOneElementOrThrow} from "../libs/element.js"; +import {tauriInvoke} from "../libs/tauri.js"; +import {renderPanelFiles} from "./files/panel.js"; +import {renderColumnFiles} from "./files/column.js"; +import {renderListFiles} from "./files/list.js"; +import {onceNavigate} from "../libs/navigation.js"; + +/** + * @typedef {Object} Entry + * @property {string} name + * @property {string} path + * @property {number} size + * @property {boolean} is_directory + * @property {boolean} is_file + * @property {boolean} is_symlink + * @property {string} permission + * @property {number} directory_item_count + * @property {number} created + * @property {number} modified + * @property {number} accessed + */ + +let currentView = 'list'; + +export const renderFiles = async () => { + const listEl = getOneElementOrThrow(document, '[data-ui=view-list]'); + const panelEl = getOneElementOrThrow(document, '[data-ui=view-panel]'); + const columnEl = getOneElementOrThrow(document, '[data-ui=view-column]'); + + const handleClick = (toView) => () => { + if (currentView !== toView) { + currentView = toView; + renderFiles(); + } + } + + const once = true; + listEl.addEventListener('click', handleClick('list'), {once}); + panelEl.addEventListener('click', handleClick('panel'), {once}); + columnEl.addEventListener('click', handleClick('column'), {once}); + + switch (currentView) { + case 'panel': + renderPanelFiles(); + break; + case 'column': + renderColumnFiles(); + break; + default: + renderListFiles(); + break; + } + + onceNavigate(() => renderFiles()); +} + +/** + * @param {string} directory + * @returns {Promise} + */ +export const getEntries = async (directory) => { + return await tauriInvoke('get_files', { directory }); +} + +/** + * Convert bytes to a human-readable size format. + * + * @param {number} bytes - The number of bytes to convert. + * @param {number} decimals - The number of decimal places to round to (optional, default is 2). + * @returns {string} - A string representing the human-readable size. + */ +export const formatBytes = (bytes, decimals = 2) => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]; +} + +export const formatDirectoryItemCount = (directoryItemCount) => { + if (directoryItemCount === 1) { + return '1 item' + } + + return `${directoryItemCount} items` +} + +export const openFile = async (path) => { + return await tauriInvoke('open_file', { path }); +} \ No newline at end of file diff --git a/src/parts/files/column.js b/src/parts/files/column.js new file mode 100644 index 0000000..66f8135 --- /dev/null +++ b/src/parts/files/column.js @@ -0,0 +1,7 @@ +import {getOneElementOrThrow} from "../../libs/element.js"; + +export const renderColumnFiles = () => { + const fileEl = getOneElementOrThrow(document, '[data-ui=files]'); + fileEl.innerHTML = ''; + console.log('column'); +} \ No newline at end of file diff --git a/src/parts/files/list.js b/src/parts/files/list.js new file mode 100644 index 0000000..33190fe --- /dev/null +++ b/src/parts/files/list.js @@ -0,0 +1,84 @@ +import {getTemplate} from "../../libs/template.js"; +import {formatBytes, formatDirectoryItemCount, getEntries, openFile} from "../files.js"; +import {getCurrentDir, navigate} from "../../libs/navigation.js"; +import {getOneElementOrThrow} from "../../libs/element.js"; +import {sep} from "../../libs/path.js"; + +export const renderListFiles = () => { + const fileEl = getOneElementOrThrow(document, '[data-ui=files]'); + const containerTemplate = getTemplate('files-list'); + + const body = containerTemplate.el('body'); + + /** + * @callback ValueFormat + * @param {any} value + * @param {Entry} entry + * @return any + */ + + /** + * @param {Entry} entry + * @param {Template} parentEl + * @param {string} name + * @param {ValueFormat} valueFormat + */ + const applyValue = (entry, parentEl, name, valueFormat = (v, e) => v) => { + if (name in entry) { + const targetEl = parentEl.el(name); + targetEl.innerText = valueFormat(entry[name], entry); + } + } + + const renderList = async () => { + const currentDirectory = getCurrentDir(); + const entries = await getEntries(currentDirectory); + + for (const entry of entries) { + const itemTemplate = getTemplate('files-list-item'); + + applyValue(entry, itemTemplate, 'name', (value, entry) => { + let prefix = ''; + if (entry.is_directory) prefix = sep; + return `${prefix}${value}`; + }); + applyValue(entry, itemTemplate, 'permission') + applyValue(entry, itemTemplate, 'modified') + applyValue( + entry, + itemTemplate, + 'size', + (value, entry) => { + if (entry.is_directory) { + return formatDirectoryItemCount(entry.directory_item_count) + } else { + return formatBytes(value); + } + } + ) + + const actionEl = itemTemplate.el('action'); + actionEl.addEventListener('click', ({currentTarget}) => { + fileEl.querySelectorAll('[data-el=body] .bg-blue-500') + .forEach(el => el.classList.remove('bg-blue-500')) + + currentTarget.classList.add('bg-blue-500'); + }); + + actionEl.addEventListener('dblclick', () => { + if (entry.is_directory) { + navigate(entry.path); + } else { + openFile(entry.path); + } + }) + + body.appendChild(itemTemplate.render()); + } + + fileEl.innerHTML = ''; + fileEl.appendChild(containerTemplate.render()); + } + + renderList(); +} \ No newline at end of file diff --git a/src/parts/files/panel.js b/src/parts/files/panel.js new file mode 100644 index 0000000..af2c1cc --- /dev/null +++ b/src/parts/files/panel.js @@ -0,0 +1,7 @@ +import {getOneElementOrThrow} from "../../libs/element.js"; + +export const renderPanelFiles = () => { + const fileEl = getOneElementOrThrow(document, '[data-ui=files]'); + fileEl.innerHTML = ''; + console.log('panel'); +} \ No newline at end of file