Add file browsing, viewing functionalities and enhance navigation

Introduced new functionalities in the file manager application for browsing, listing and opening files. We have added a new command 'get_files' in 'main.rs', which reads entries in a directory and fetches associated metadata. This allows users to view files in a selected directory. Additionally, the command 'open_file' has been added for opening files.
Simultaneously, frontend changes have been made such as: new templates for file lists, handlers for view switching in 'files.js', and rendered files in each mode.
The 'navigation.js' error handling has been improved for invalid navigation.
Improved user experience by adding a breadcrumbs feature for better navigation. This is performed by the newly introduced 'breadcrumbs.js' file which maintains the history of navigated directories.
Overall, these enhanced functionalities improve navigation and file management efficiency for end-users.
This commit is contained in:
Ian Wijma 2023-11-27 23:36:35 +11:00
parent e3716d7e89
commit e8969a4306
10 changed files with 364 additions and 8 deletions

View File

@ -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<EntryMetaData> {
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<EntryMetaData> = 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");

View File

@ -19,7 +19,19 @@
},
"fs": {
"all": true,
"scope": ["**"]
"scope": [
"**",
"$APP",
"$APP/**",
"$CACHE",
"$CACHE/**",
"$CONFIG",
"$CONFIG/**",
"$LOG",
"$LOG/**",
"$HOME/**",
"$HOME/.*"
]
},
"dialog": {
"all": true

View File

@ -8,7 +8,7 @@
<title>File Browser</title>
</head>
<body class="h-screen max-h-screen flex flex-col">
<body class="h-screen max-h-screen flex flex-col overflow-hidden">
<header class="bg-amber-500 w-screen h-12 flex items-center justify-between">
<span class="flex">
<button>Back</button>
@ -53,5 +53,44 @@
<template data-template="breadcrumbs">
<button data-el="action" class="after:content-['/']"></button>
</template>
<template data-template="files-list">
<div class="w-full h-full overflow-y-scroll">
<table class="w-full">
<thead>
<tr class="sticky top-0 bg-red-500">
<th>Name</th>
<th>Size</th>
<th>Permissions</th>
<th>Modified</th>
</tr>
</thead>
<tbody data-el="body"></tbody>
</table>
</div>
</template>
<template data-template="files-list-item">
<tr data-el="action">
<td data-el="name"></td>
<td data-el="size"></td>
<td data-el="permission"></td>
<td data-el="modified"></td>
</tr>
</template>
<template data-template="files-panel">
</template>
<template data-template="files-panel-item">
</template>
<template data-template="files-column">
</template>
<template data-template="files-column-item">
</template>
</body>
</html>

View File

@ -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();

View File

@ -47,8 +47,4 @@ export const getCurrentDir = () => currentDir;
const logDir = async () => console.log({
currentDir,
items: await readDir(currentDir)
});
onNavigate(logDir);
logDir();
});

View File

@ -1,5 +1,10 @@
import {getElementOrThrow, getOneElementOrThrow} from "./element.js";
/**
* @typedef {Object} Template
* @property {function} el
* @property {function} render
*/
/**
* @param {string} name

93
src/parts/files.js Normal file
View File

@ -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<Entry[]>}
*/
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 });
}

View File

@ -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');
}

84
src/parts/files/list.js Normal file
View File

@ -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();
}

7
src/parts/files/panel.js Normal file
View File

@ -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');
}