feat: project 3

This commit is contained in:
Kiana Sheibani 2025-12-04 17:37:19 -05:00
parent 8f4463782c
commit b9a362d490
Signed by: toki
GPG key ID: 6CB106C25E86A9F7
15 changed files with 5425 additions and 15 deletions

1
.gitignore vendored
View file

@ -36,6 +36,7 @@
*.x86_64
*.hex
build/
target/
# Debug files
*.dSYM/

55
flake.lock generated
View file

@ -1,5 +1,41 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1763938834,
"narHash": "sha256-j8iB0Yr4zAvQLueCZ5abxfk6fnG/SJ5JnGUziETjwfg=",
"owner": "ipetkov",
"repo": "crane",
"rev": "d9e753122e51cee64eb8d2dddfe11148f339f5a2",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1764485195,
"narHash": "sha256-VPbuGvsS3rN+CR75xTIG6kk5v1SSQ62DDtz/WImdtfo=",
"owner": "nix-community",
"repo": "fenix",
"rev": "2e31077af24c47ea9057fcb152fa0386444e582a",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1756819007,
@ -18,10 +54,29 @@
},
"root": {
"inputs": {
"crane": "crane",
"fenix": "fenix",
"nixpkgs": "nixpkgs",
"systems": "systems"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1764443484,
"narHash": "sha256-0ogtpJuadSpl/Y04GsxtUYHD4GW4ZNV8StJTS2MVvEE=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "66c11a2880f6a929e3ba811aa6102c4d9fe2f77a",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,

View file

@ -4,9 +4,13 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
systems.url = "github:nix-systems/default";
crane.url = "github:ipetkov/crane";
fenix.url = "github:nix-community/fenix";
fenix.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, systems, ... }:
outputs = inputs@{ self, nixpkgs, systems, crane, fenix, ... }:
let
subdirs = [
"assignment1"
@ -14,6 +18,7 @@
"assignment3"
"project1"
"project2"
"project3"
];
eachSystem = nixpkgs.lib.genAttrs (import systems);
@ -23,7 +28,7 @@
in pkgs.lib.mergeAttrsList
(builtins.map (d:
builtins.mapAttrs
(_: v: pkgs.callPackage v {})
(_: v: pkgs.callPackage v { inherit inputs; })
(import ./${d}/${file}))
subdirs));
in {
@ -33,7 +38,7 @@
devShells = eachSystem (system:
let pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.callPackage ./shell.nix {};
default = import ./shell.nix { inherit pkgs crane fenix; };
});
};
}

View file

@ -7,8 +7,6 @@
\title{CS3502 Project 2: CPU Scheduling Simulator}
\author{Kiana Sheibani}
\definecolor{lgray}{gray}{0.9}
\begin{document}
\maketitle
\newpage

4158
project3/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

8
project3/Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "file_manager"
version = "0.1.0"
edition = "2024"
[dependencies]
winit = "0.30.12"
xilem = "0.4"

37
project3/default.nix Normal file
View file

@ -0,0 +1,37 @@
rec {
project3 = file-manager;
file-manager = { pkgs, inputs }:
let
inherit (inputs) crane fenix;
toolchain = fenix.packages.${pkgs.system}.complete.withComponents [
"rustc" "rust-std" "cargo" "rust-docs" "rustfmt" "clippy" "rust-src"
];
craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
commonArgs = {
src = craneLib.cleanCargoSource ./.;
strictDeps = true;
buildInputs = with pkgs; [
pkg-config
fontconfig
wayland
wayland-protocols
wayland-scanner
vulkan-loader
libxkbcommon
xorg.libxcb
xorg.libX11
xorg.libXcursor
xorg.libXi
xorg.libXrandr
xorg.libXxf86vm
];
passthru.commonArgs = commonArgs;
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
crate = craneLib.buildPackage (commonArgs // { inherit cargoArtifacts; });
in crate;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

237
project3/report/report.tex Normal file
View file

@ -0,0 +1,237 @@
\documentclass{article}
\usepackage{graphicx}
\usepackage{xcolor}
\usepackage{minted}
\usepackage{hyperref}
\title{CS3502 Project 3: File Manager}
\author{Kiana Sheibani}
\begin{document}
\maketitle
\newpage
\section{Introduction}
Circumstance compels us to program a GUI file manager from scratch, as it so
often does.
\subsection{Objective}
A file manager is a tool for reading and manipulating a file-system, typically
the primary file-system of the machine. It allows the user to perform various
operations on the files and directories (\emph{entries} in aggregate) of the
file-system, such as to:
\begin{itemize}
\item \textbf{Create} new entries;
\item \textbf{Read} and \textbf{Update} the contents of entries;
\item \textbf{Delete} entries.
\end{itemize}
Commonly, file managers are designed to allow this manipulation by having the
user traverse the filesystem one directory at a time, moving from a single
working directory to its sub- or parent directories.
\subsection{Resources}
Implementing a file manager is not a trivial task. Properly navigating a
file-system requires quite a bit of engineering around complex systems,
unintuitive edge cases and strange errors, and some languages and frameworks are
better equipped to handle this than others.
After considering this fact, I decided on \href{https://rust-lang.org/}{Rust} as
my language of choice. Not only do I have significant prior experience with it,
but its low-level nature and near-slavish dedication to correctness make it
ideal for working with file-systems.
Once I had my language picked, I searched for GUI libraries I could use for the
front-end. It was not long before I found
\href{https://crates.io/crates/xilem/}{Xilem}, an experimental cross-platform UI
toolkit. It was still somewhat unstable, but I decided it would be perfectly
fine for my purposes.%
\footnote{This assumption turned out to be rather short-sighted, but I made it
work as best I could.}
\section{Design and Architecture}
\subsection{Front-End Design}
\begin{figure}[tbh]
\includegraphics[width=\linewidth]{assets/gui.png}
\caption{The UI of the file manager.}
\label{fig:gui}
\end{figure}
The UI design of the file manager is somewhat inspired by
\href{https://github.com/ranger/ranger}{{\texttt{ranger}}}, though heavily
simplified. The current directory path is displayed at the top, and its entries
are listed in the main panel below it. When a file is opened, its contents
appear on a panel to the right, and remain there until another file is opened.
The file-system can be navigated by either double-clicking on a subdirectory, or
selecting it and then clicking the \emph{Open} button at the bottom. To move up
the directory tree, a component of the topmost path display can be clicked on.
There are also several other action buttons along the bottom; these operate on
the currently selected entry (aside from the \emph{New File} and \emph{New Dir}
actions) and using them pops up a dialog box to confirm the action. The dialog
box popup is also used to display errors whenever they occur.
\begin{figure}[tbh]
\includegraphics[width=\linewidth]{assets/dialog.png}
\caption{The file manager showing a file renaming dialog.}
\label{fig:dialog}
\end{figure}
\subsection{Program Architecture}
The structure of the program can be conceptually divided into two halves: the
file-system management subroutines, and the GUI logic.
\subsubsection{File-System Management}
Internally, an entry in the current directory is stored as an \texttt{EntryData}
object (Listing~\ref{listing:EntryData}), which holds the entry's name and other
metadata. The basic file-system operations, such as renaming and deleting
entries, all operate on this data structure.
\begin{listing}[!ht]
\inputminted[
firstline=7, lastline=17,
fontsize=\footnotesize,
linenos,
frame=lines,
framesep=2mm,
baselinestretch=1.1,
]{rust}{../src/files.rs}
\vspace*{-\baselineskip}
\caption{\texttt{files.rs} --- Definition of \texttt{EntryData}}
\label{listing:EntryData}
\end{listing}
Reading and editing a file, on the other hand, requires a different structure to
store and manipulate the file handle. This is defined as \texttt{FileData}
(Listing~\ref{listing:FileData}). When a file is opened, the file handle is
retained for the entire duration the file is viewed, only being closed when the
file is exited. The \texttt{files} module of the program implements an
\texttt{open} function to create this data structure and a \texttt{save}
function to write to the file handle that it holds.
\begin{listing}[!ht]
\inputminted[
firstline=72, lastline=85,
fontsize=\footnotesize,
linenos,
frame=lines,
framesep=2mm,
baselinestretch=1.1,
]{rust}{../src/files.rs}
\vspace*{-\baselineskip}
\caption{\texttt{files.rs} --- Definition of \texttt{FileData}}
\label{listing:FileData}
\end{listing}
\subsubsection{GUI \& Error Handling}
Xilem is a reactive GUI framework based on composable widgets, similar to Qt.
The system is based around a data structure representing the app's state; in the
case of this program, the \texttt{AppState} type includes the current directory,
selected entry, and state for any open panels, such as the \texttt{FileData} for
the open file.
\texttt{AppState} also includes the \texttt{DialogState}, which is an enum with
one value per type of dialog that can be opened: \texttt{NewFile},
\texttt{NewDir}, \texttt{Rename}, \texttt{Delete}, and \texttt{Error}, where the
last value also includes an error message to display. Determining when to show
these errors is simple: thanks to Rust's aforementioned obsession with
correctness, every file-system routine it provides that could ever conceivably
result in any sort of error wraps its return type in a \texttt{std::io::Result}
data structure, which forces the programmer to handle the error case before the
actual output can be retrieved. This is why I chose Rust for this project, and
it led to very simple and easy-to-understand file management code.
\section{Testing \& Edge Cases}
In order to ensure the robustness of my work, I regularly tested it manually to
make sure that it functioned properly and handled any of the weird edge cases
file-system code is prone to. Some of these issues I was able to fully
eliminate, while others were a bit more stubborn.
\subsection{File-System Race Conditions}
The file manager is only one of many processes that can access the file-system,
which can lead to issues if two processes are trying to modify the same
directory at the same time. For example, if another process deletes a file in
the current directory of the file manager, an orphan entry may be left over that
no longer points to an existing file. While the file manager will not
automatically detect that this has occurred, it will throw an error if the user
tries to access an entry that no longer exists.
\subsection{Uncommon Entry Types}
So far, the only two entry types mentioned have been files and subdirectories,
the two most common types. There are others, however; symbolic links, or
``symlinks'', and more esoteric file types like named pipes, device files, etc.
Symlinks are of particular concern, as they violate the file-system's otherwise
perfect tree structure.
To avoid hard-to-debug issues down the line, I decided on a policy for handling
symlinks from the start:
\begin{itemize}
\item In the main panel, symlinks are not followed and are displayed
distinctly from the entries that they point to, including their
metadata. This is done for performance reasons.
\item Opening a symlink follows it to its target, with the caveat that
if a symlink is followed into a subdirectory, the displayed path is
canonicalized. This simplifies the navigation.
\end{itemize}
All other entry types simply throw an error when the user tries to open them, as
do any files that do not contain UTF-8 text.
\subsection{Unusual Entry Names}
While most entry names are simple Latin letters and numbers, depending on the
operating system there are many other possibilities. Unix-based systems
typically have UTF-8 entry names, though they technically allow any string of
bytes except for \texttt{0x00} (NUL) or \texttt{0x2F} (/). Windows, on the other
hand, has historically used UTF-16 for its entry names, as it predated the wide
adoption of the superior UTF-8.
To allow cross-platform programs to navigate this confusion, the Rust standard
library provides the \texttt{OsString} type, which represents a string in the
format the target OS prefers. Due to this and some careful programming, the file
manager will never crash due to unsupported entry names (at least on Linux; I
have not personally tested Windows). Unfortunately, while non-Unicode entry
names can be read, they cannot be entered into dialog text fields. This is an
inherent limitation of Xilem that is unlikely to change.
\subsection{Large File Sizes}
This is the largest issue that still remains in the file manager. Due to the
fact that a full buffer of an open file's contents must be maintained, the file
editor is exceedingly slow with files over a few kilobytes, and trying to open
anything larger than a megabyte has a chance of causing the application to
crash. Ideally, the program would use some sort of line buffering scheme to
avoid having to update the file all at once, but I was not able to implement
anything that worked well.
\section{Conclusion}
After roughly a week's effort, a basic file manager has been achieved. Though
the visual aesthetic and the file editing feature may be improved, this
serves as a strong foundation for a well-programmed application.
\subsection{Learning Outcomes}
While I most likely started from a more knowledgeable place on this subject than
most in this class, I did learn some new things from this process. I was not
nearly as knowledgeable on the minutiae of OS file-systems when I started this
project as I am now, nor was I as experienced in writing GUI applications that
properly interface with that OS minutia. At the end of it all, I am glad that
something of some use came out of this.
\end{document}

725
project3/src/app.rs Normal file
View file

@ -0,0 +1,725 @@
//! GUI code for the primary app logic.
use std::ffi::OsString;
use std::fs::File;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use xilem::{
masonry::{
parley::{FontFamily, FontStack, GenericFamily},
properties::types::AsUnit,
},
palette::css::*,
style::*,
view::*,
*,
};
use crate::files::{self, EntryData, FileData};
// -- CONSTANTS
// Colors
const BG: Color = Color::from_rgb8(24, 25, 30);
const BG_ALT: Color = Color::from_rgb8(48, 52, 64);
const BG_ALT2: Color = Color::from_rgb8(90, 100, 125);
const FG: Color = Color::from_rgb8(205, 215, 255);
const BG_SEL: Color = Color::from_rgb8(32, 100, 232);
// Fonts
const TEXT_SIZE: f32 = 14.;
const FONT: FontStack<'static> = FontStack::Single(FontFamily::Generic(GenericFamily::SansSerif));
const FONT_MONO: FontStack<'static> =
FontStack::Single(FontFamily::Generic(GenericFamily::Monospace));
// -- APP STATE
/// The app's global state.
pub struct AppState {
// Directory Viewer
/// The directory currently being viewed.
///
/// This will always be an absolute and canonicalized path.
dir: PathBuf,
/// A cache of the entries for the current directory.
///
/// This may be `None` if the directory hasn't been read yet,
/// or if an error occurred while reading.
entries: Option<Vec<EntryData>>,
/// The currently selected directory entry.
selected: Option<usize>,
// File Viewer
/// The data associated with the opened file.
file_viewer: Option<FileData>,
// Dialog
/// The current type/state of the dialog, if one is open.
dialog: Option<DialogState>,
/// The buffer for the dialog's text field.
///
/// This is stored separately from the dialog state for convenience.
dialog_text_field: String,
}
/// The current state of an open dialog.
///
/// This primarily determines what type of dialog is opened.
#[derive(Clone, PartialEq, Eq)]
enum DialogState {
/// An error dialog.
Error {
/// The error message to display.
message: String,
},
/// A dialog for creating a new file.
NewFile,
/// A dialog for creating a new directory.
NewDir,
/// A dialog for renaming a file or directory.
Rename,
/// A dialog for deleting a file or directory.
Delete,
}
impl AppState {
/// Creates a new app state viewing the given directory.
pub fn new(dir: PathBuf) -> Self {
Self {
dir,
entries: None,
selected: None,
dialog: None,
dialog_text_field: String::new(),
file_viewer: None,
}
}
/// Returns a reference to the selected directory entry.
fn selected_entry(&self) -> Option<&EntryData> {
Some(&self.entries.as_ref()?[self.selected?])
}
// -- ACTIONS
/// Change the current directory.
///
/// The path `dir` must be absolute and canonicalized.
fn change_dir(&mut self, dir: &Path) -> std::io::Result<()> {
let old_entries = self.entries.take();
self.entries = Some(files::get_entries(dir)?);
std::env::set_current_dir(dir).inspect_err(|_| {
// Revert entry cache if chdir fails
self.entries = old_entries;
})?;
self.dir = dir.to_owned();
self.selected = None;
Ok(())
}
/// Populate the entry cache for the current directory.
fn populate_entries(&mut self) -> std::io::Result<()> {
self.change_dir(&self.dir.clone())
}
/// Select the entry at `index`.
fn select(&mut self, index: usize) {
self.selected = Some(index);
}
/// Open the selected entry.
///
///
/// If the selected entry is a directory, view it.
/// If it is a file, open it in the file viewer.
/// If it is a symlink, follow it.
/// Otherwise, display an error.
fn open(&mut self) -> std::io::Result<()> {
if let Some(data) = self.selected_entry() {
let canon = Path::new(&data.name).canonicalize()?;
if canon.is_dir() {
self.change_dir(&canon)?;
} else if canon.is_file() {
self.file_viewer = Some(files::open(&data.name)?);
} else {
self.error(format!(
"Error opening {:?}:\nUnsupported file type",
data.name
));
}
}
Ok(())
}
/// Create a new file.
fn new_file(&mut self) -> std::io::Result<()> {
File::create_new(&self.dialog_text_field)?;
self.populate_entries()?;
Ok(())
}
/// Create a new directory.
fn new_dir(&mut self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.dialog_text_field)?;
self.populate_entries()?;
Ok(())
}
/// Create the currently selected entry.
fn rename(&mut self) -> std::io::Result<()> {
if let Some(entry) = self.selected_entry() {
files::rename_entry(&entry, &self.dialog_text_field)?;
self.populate_entries()?;
}
Ok(())
}
/// Delete the currently selected entry.
fn delete(&mut self) -> std::io::Result<()> {
if let Some(entry) = self.selected_entry() {
files::delete_entry(&entry)?;
self.populate_entries()?;
}
Ok(())
}
/// Display a dialog with the given state.
fn dialog(&mut self, dialog_state: DialogState) {
self.dialog = Some(dialog_state);
self.dialog_text_field = String::new();
}
/// Display an error dialog with the given message.
fn error(&mut self, message: String) {
self.dialog = Some(DialogState::Error { message });
}
}
// -- VIEWS
macro_rules! custom_label {
($text:expr $(, $align:ident)? $(,)?) => {
label($text)
.text_size(TEXT_SIZE)
$(.text_alignment(xilem::TextAlign::$align))?
.font(FONT)
.color(FG)
.disabled_color(BG_ALT)
};
}
macro_rules! custom_label_mono {
($text:expr $(, $align:ident)? $(,)?) => {
label($text)
.text_size(TEXT_SIZE)
$(.text_alignment(xilem::TextAlign::$align))?
.font(FONT_MONO)
.color(FG)
.disabled_color(BG_ALT)
};
}
macro_rules! custom_button {
($text:expr, $callback:expr $(, $disabled:expr)? $(,)?) => {
button(custom_label!($text), $callback)$(.disabled($disabled))?
.background_color(BG_ALT)
.active_background_color(BG_ALT2)
.border_color(BG_ALT2)
.hovered_border_color(FG)
.disabled_background_color(BG)
};
}
/// A view to display the currently viewed directory path.
fn path_display(state: &mut AppState) -> impl WidgetView<AppState> + use<> {
// Iterate over ancestors of current dir and generate one button for each
let mut buttons = (&state.dir)
.ancestors()
.map(|p| {
let p_buf = p.to_owned();
custom_button!(
if let Some(name) = p.file_name() {
// Display filename component of each ancestor
name.display().to_string()
} else {
// `p` is always absolute and canonicalized, so if it
// doesn't have a filename component it must be root
p.display().to_string()
},
move |state: &mut AppState| {
if state.dialog.is_some() {
return;
}
// Change current directory on button press
state.change_dir(&p_buf).unwrap_or_else(|err| {
state.error(format!(
"Error reading directory {:?}:\n{}",
p_buf.display(),
err
))
})
},
)
.background_color(BG)
.active_background_color(BG_ALT)
.border_color(BG_ALT)
.hovered_border_color(BG_ALT2)
.corner_radius(2.)
.padding(Padding::from_vh(4., 6.))
})
.collect::<Vec<_>>();
// The `ancestors` iterator yields paths ascending towards root, so
// the button order must be reversed
buttons.reverse();
flex_row(buttons)
.gap(0.px())
.must_fill_major_axis(true)
.main_axis_alignment(MainAxisAlignment::Start)
.cross_axis_alignment(CrossAxisAlignment::Center)
.padding(Padding::from_vh(10., 15.))
.border(BG_ALT, 2.)
}
/// A view to display a directory entry.
fn entry_view(state: &mut AppState, entry: EntryData) -> impl WidgetView<AppState> + use<> {
let selected = state.selected == Some(entry.index);
button(
flex_row(custom_label!(entry.name.display().to_string())).must_fill_major_axis(true),
move |state: &mut AppState| {
if state.dialog.is_some() {
return;
}
if selected {
// Open on click if already selected (double click)
state.open().unwrap_or_else(|err| {
state.error(format!("Error opening entry {:?}:\n{}", entry.name, err))
});
} else {
// Select on click
state.select(entry.index);
}
},
)
.corner_radius(0.)
.border_color(TRANSPARENT)
.hovered_border_color(TRANSPARENT)
.background_color(if selected { BG_SEL } else { TRANSPARENT })
.active_background_color(if selected { BG_SEL } else { BG_ALT })
.padding(Padding::vertical(4.))
}
/// A view to display the entries of the current directory.
fn dir_view(state: &mut AppState) -> impl WidgetView<AppState> + use<> {
// Populate entries if they are unavailable
if state.entries.is_none() {
state.populate_entries().unwrap_or_else(|err| {
state.error(format!("Error reading directory {:?}: {}", state.dir, err));
});
}
virtual_scroll(
0..(state.entries.as_ref().unwrap().len() as i64),
|state: &mut AppState, i| {
let i = i as usize;
let entries = state.entries.as_ref().unwrap();
if i < entries.len() {
entry_view(state, entries[i].clone())
} else {
// HACK: For some reason we need to have a placeholder here
// in case Xilem calls the callback with an out-of-bounds index
entry_view(
state,
EntryData {
index: i,
name: OsString::from(""),
metadata: state.dir.metadata().unwrap(),
},
)
}
},
)
}
fn entry_metadata(state: &mut AppState) -> Option<impl WidgetView<AppState> + use<>> {
fn display_size(size: u64) -> String {
const UNITS: &[&'static str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
const KILO: f64 = (1 << 10) as f64;
let mut unit = 0;
let mut size_f = size as f64;
while size_f >= KILO && unit < UNITS.len() - 1 {
unit += 1;
size_f /= KILO;
}
if unit == 0 {
format!(" {}B", size)
} else {
format!(" {:.2}{}", size_f, UNITS[unit])
}
}
#[cfg(unix)]
fn display_permissions(mode: u32) -> String {
"rwxrwxrwx"
.chars()
.enumerate()
.map(|(i, c)| if mode >> (8 - i) & 1 == 1 { c } else { '-' })
.collect()
}
fn display_file_type(entry: &EntryData) -> String {
String::from(if entry.metadata.is_file() {
"File"
} else if entry.metadata.is_dir() {
"Directory"
} else if entry.metadata.is_symlink() {
"Symlink"
} else {
"Other"
})
}
if let Some(entry) = state.selected_entry() {
// Unix - Display permission bits
#[cfg(unix)]
let permissions = display_permissions(entry.metadata.permissions().mode());
// Non-Unix - Read-only flag
#[cfg(not(unix))]
let permissions = if entry.metadata.permissions().readonly() {
"Read-Only"
} else {
""
};
let size = if entry.metadata.is_file() {
&display_size(entry.metadata.len())
} else {
""
};
let file_type = display_file_type(entry);
Some(custom_label_mono!(
&format!(
"{}{} - {} ({})",
permissions,
size,
entry.name.display(),
file_type
)[..]
))
} else {
None
}
}
/// A view for the buttons to trigger directory entry operations.
fn entry_ops(state: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex_row((
custom_button!("New File", |state: &mut AppState| {
if state.dialog.is_some() {
return;
}
state.dialog(DialogState::NewFile);
}),
custom_button!("New Dir", |state: &mut AppState| {
if state.dialog.is_some() {
return;
}
state.dialog(DialogState::NewDir);
}),
custom_button!(
"Open",
|state: &mut AppState| {
if state.dialog.is_some() {
return;
}
state.open().unwrap_or_else(|err| {
state.error(format!(
"Error opening entry {:?}:\n{}",
state.selected_entry().unwrap().name,
err
));
});
},
state.selected.is_none(),
),
custom_button!(
"Rename",
|state: &mut AppState| {
if state.dialog.is_some() {
return;
}
state.dialog(DialogState::Rename);
},
state.selected.is_none(),
),
custom_button!(
"Delete",
|state: &mut AppState| {
if state.dialog.is_some() {
return;
}
state.dialog(DialogState::Delete);
},
state.selected.is_none(),
),
))
}
/// A view for the main file viewer.
fn file_view() -> impl WidgetView<AppState> + use<> {
virtual_scroll(0..1, |state: &mut AppState, _| {
let file_state = state.file_viewer.as_ref().unwrap();
text_input(
file_state.buffer.clone(),
// Run on text field changed
|state: &mut AppState, input: String| {
let file_state = state.file_viewer.as_mut().unwrap();
// Update file buffer
file_state.buffer = input;
// Set modified flag
file_state.modified = true;
},
)
// Disable interaction if file is readonly
.disabled(file_state.readonly || state.dialog.is_some())
// Allow newline insertion
.insert_newline(InsertNewline::OnEnter)
.text_color(FG)
.disabled_text_color(FG)
.border_width(0.)
})
}
/// A view for the buttons to save and close the file viewer.
fn file_ops(state: &mut AppState) -> impl WidgetView<AppState> + use<> {
let file_state = state.file_viewer.as_ref().unwrap();
let file_name = file_state.name.display().to_string();
flex_row((
custom_label_mono!(if file_state.readonly {
format!("{} (Read-Only)", file_name)
} else if file_state.modified {
format!("{} (*)", file_name)
} else {
file_name
}),
FlexSpacer::Flex(1.0),
custom_button!(
"Save",
|state: &mut AppState| {
if state.dialog.is_some() {
return;
}
files::save(state.file_viewer.as_mut().unwrap()).unwrap_or_else(|err| {
state.error(format!(
"Error saving file {:?}:\n{}",
state.file_viewer.as_ref().unwrap().name,
err
));
});
},
file_state.readonly,
),
custom_button!("Close", |state: &mut AppState| {
if state.dialog.is_some() {
return;
}
state.file_viewer = None;
}),
))
.padding(Padding::all(8.))
}
/// A view for the dialog popup.
fn dialog(state: &mut AppState, dialog_state: DialogState) -> impl WidgetView<AppState> + use<> {
// Error Dialog
let error = if let DialogState::Error { message } = &dialog_state {
Some((
custom_label!(&message[..], Center),
custom_button!("OK", |state: &mut AppState| {
state.dialog = None;
}),
))
} else {
None
};
// New File Dialog
let new_file = if &dialog_state == &DialogState::NewFile {
let action = |state: &mut AppState| {
state.dialog = None;
if !state.dialog_text_field.is_empty() {
state.new_file().unwrap_or_else(|err| {
state.error(format!(
"Error creating file {:?}:\n{}",
state.dialog_text_field, err
));
});
}
};
Some((
custom_label!("Name of new file:"),
text_input(
state.dialog_text_field.clone(),
|state: &mut AppState, input: String| state.dialog_text_field = input,
)
.on_enter(move |state, _| action(state)),
flex_row((
custom_button!("Create", action, state.dialog_text_field.is_empty()),
custom_button!("Cancel", |state: &mut AppState| {
state.dialog = None;
}),
)),
))
} else {
None
};
let new_dir = if &dialog_state == &DialogState::NewDir {
let action = |state: &mut AppState| {
state.dialog = None;
if !state.dialog_text_field.is_empty() {
state.new_dir().unwrap_or_else(|err| {
state.error(format!(
"Error creating directory {:?}:\n{}",
state.dialog_text_field, err
));
});
}
};
Some((
custom_label!("Name of new directory:"),
text_input(
state.dialog_text_field.clone(),
|state: &mut AppState, input: String| state.dialog_text_field = input,
)
.on_enter(move |state, _| action(state)),
flex_row((
custom_button!("Create", action, state.dialog_text_field.is_empty()),
custom_button!("Cancel", |state: &mut AppState| {
state.dialog = None;
}),
)),
))
} else {
None
};
let rename = if &dialog_state == &DialogState::Rename {
let action = move |state: &mut AppState| {
state.dialog = None;
if !state.dialog_text_field.is_empty() {
state.rename().unwrap_or_else(|err| {
state.error(format!(
"Error renaming {:?} to {:?}:\n{}",
state.selected_entry().unwrap().name,
state.dialog_text_field,
err
));
});
}
};
Some((
custom_label!("New name/location:"),
text_input(
state.dialog_text_field.clone(),
|state: &mut AppState, input: String| state.dialog_text_field = input,
)
.on_enter(move |state, _| action(state)),
flex_row((
custom_button!("Rename", action, state.dialog_text_field.is_empty()),
custom_button!("Cancel", |state: &mut AppState| {
state.dialog = None;
}),
)),
))
} else {
None
};
let delete = if &dialog_state == &DialogState::Delete {
Some((
custom_label!("Are you sure you want to delete?"),
flex_row((
custom_button!("OK", |state: &mut AppState| {
state.dialog = None;
state.delete().unwrap_or_else(|err| {
state.error(format!(
"Error deleting {:?}:\n{}",
state.selected_entry().unwrap().name,
err
));
});
}),
custom_button!("Cancel", |state: &mut AppState| {
state.dialog = None;
}),
)),
))
} else {
None
};
sized_box(
flex_col((error, new_file, new_dir, rename, delete))
.corner_radius(6.)
.background_color(BG)
.border(BG_ALT, 2.)
.padding(Padding::all(16.)),
)
.width(320.px())
}
/// The primary app logic.
pub fn app_main(state: &mut AppState) -> impl WidgetView<AppState> + use<> {
zstack((
flex_col((
path_display(state),
split(
flex_col((
dir_view(state).flex(1.0),
entry_metadata(state),
entry_ops(state),
))
.cross_axis_alignment(CrossAxisAlignment::Start)
.padding(Padding::all(8.)),
flex_col(if state.file_viewer.is_some() {
Some((file_view().flex(1.0), file_ops(state)))
} else {
None
})
.cross_axis_alignment(CrossAxisAlignment::Fill),
)
.min_size(500.px(), 0.px())
.bar_size(if state.file_viewer.is_some() {
3.px()
} else {
0.px()
})
// To hide the file viewer when no file is open, we make the
// splitter invisible and non-interactable
.split_point(if state.file_viewer.is_some() {
0.5
} else {
1.0
})
.draggable(state.file_viewer.is_some())
.flex(1.0),
))
.cross_axis_alignment(CrossAxisAlignment::Fill)
.background_color(BG),
if let Some(dialog_state) = &state.dialog {
Some(dialog(state, dialog_state.clone()))
} else {
None
},
))
}

133
project3/src/files.rs Normal file
View file

@ -0,0 +1,133 @@
//! Datatypes and operations for manipulating the filesystem.
use std::ffi::OsString;
use std::fs::*;
use std::io::*;
use std::path::{Path, MAIN_SEPARATOR};
/// A file/subdirectory entry in a directory.
#[derive(Clone)]
pub struct EntryData {
/// The index of the entry in the list.
pub index: usize,
/// The name of the entry.
pub name: OsString,
/// The metadata associated with the entry
/// (size, permissions, etc).
pub metadata: Metadata,
}
/// Access and return the entries of the directory `dir`.
///
/// The entries are returned in code-point lexicographic order
/// (usually the same as alphabetical order).
pub fn get_entries(dir: &Path) -> Result<Vec<EntryData>> {
let mut entries: Vec<_> = dir.read_dir()?.try_collect()?;
entries.sort_unstable_by_key(|entry| entry.file_name());
entries
.into_iter()
.enumerate()
.map(|(i, entry)| {
Ok::<_, Error>(EntryData {
index: i,
name: entry.file_name(),
metadata: entry.metadata()?,
})
})
.try_collect()
}
/// Rename/Move the directory entry to the given location.
///
/// The string `to` is either the new name of the entry or the location
/// of the directory to move the entry into. The latter is assumed if:
/// 1. The path points to an existing non-empty directory, or
/// 2. The path ends with a separator ("/" on Unix, "\" on Windows).
///
/// The target location's parent directories are automatically created.
pub fn rename_entry(entry: &EntryData, to: &str) -> Result<()> {
let to_path = Path::new(to);
if to.ends_with(MAIN_SEPARATOR) || (to_path.is_dir() && to_path.read_dir()?.next().is_some()) {
std::fs::create_dir_all(to_path)?;
std::fs::rename(&entry.name, Path::join(to_path, &entry.name))?;
} else {
if let Some(parent) = to_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(&entry.name, to_path)?;
}
Ok(())
}
/// Delete the file/directory corresponding to `entry`.
///
/// Directories are deleted recursively.
pub fn delete_entry(entry: &EntryData) -> Result<()> {
if entry.metadata.is_dir() {
std::fs::remove_dir_all(&entry.name)
} else {
std::fs::remove_file(&entry.name)
}
}
/// Data associated with a currently open file.
pub struct FileData {
/// The name of the file.
pub name: OsString,
/// The file handle.
pub handle: File,
/// A buffer storing the contents of the file.
pub buffer: String,
/// Whether the file handle is read-only.
pub readonly: bool,
/// Whether the buffer is modified
/// (different from the contents of the file).
pub modified: bool,
}
/// Open and return the file pointed to by `path`.
pub fn open(path: impl AsRef<Path>) -> Result<FileData> {
let mut readonly = false;
// Try opening with write
let mut handle = File::options()
.write(true)
.read(true)
.open(&path)
.or_else(|err| match err.kind() {
ErrorKind::PermissionDenied => {
// If `PermissionDenied` error, try opening read-only
readonly = true;
File::options().read(true).open(&path)
}
// Pass through all other errors
_ => Err(err),
})?;
// Allocate String buffer to correct size
let size = handle.metadata()?.len() as usize;
let mut buffer = String::with_capacity(size);
handle.read_to_string(&mut buffer)?;
Ok(FileData {
name: path.as_ref().file_name().unwrap().to_owned(),
handle,
buffer,
readonly,
modified: false,
})
}
/// Save the given file to disk.
pub fn save(file: &mut FileData) -> Result<()> {
if file.modified {
// Move file cursor to beginning
file.handle.rewind()?;
// Truncate file
file.handle.set_len(0)?;
// Write contents of buffer
file.handle.write_all(file.buffer.as_bytes())?;
// Flush write buffer
file.handle.flush()?;
file.modified = false;
}
Ok(())
}

28
project3/src/main.rs Normal file
View file

@ -0,0 +1,28 @@
#![feature(iterator_try_collect)]
use std::path::{PathBuf, MAIN_SEPARATOR_STR};
use winit::error::EventLoopError;
use xilem::{EventLoop, WindowOptions, Xilem};
mod app;
mod files;
use app::*;
fn main() -> Result<(), EventLoopError> {
// Get start directory, first available of:
// - Current directory
// - Home directory
// - Filesystem root
let start_dir = std::env::current_dir()
.ok()
.or_else(|| std::env::home_dir())
.unwrap_or_else(|| PathBuf::from(MAIN_SEPARATOR_STR));
// Start GUI app
let app = Xilem::new_simple(
AppState::new(start_dir),
app_main,
WindowOptions::new("File Manager"),
);
app.run_in(EventLoop::with_user_event())
}

1
project3/test.nix Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -1,13 +1,37 @@
{ mkShell,
llvmPackages_21,
dotnet-sdk
{ pkgs ? import <nixpkgs> {},
crane ? builtins.getFlake "github:ipetkov/crane",
fenix ? builtins.getFlake "github:nix-community/fenix",
}:
mkShell {
packages = [
# GCC is provided by default
# Provides clangd
llvmPackages_21.clang-tools
let
fenixPkgs = fenix.packages.${pkgs.system};
toolchain = fenixPkgs.complete.withComponents [
"rustc" "rust-std" "cargo" "rust-docs" "rustfmt" "clippy" "rust-src"
];
craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
# Rust dev tools (for P3)
rustPackages = with pkgs; [
pkg-config
fontconfig
wayland
wayland-protocols
wayland-scanner
vulkan-loader
libxkbcommon
xorg.libxcb
xorg.libX11
xorg.libXcursor
xorg.libXi
xorg.libXrandr
xorg.libXxf86vm
];
in craneLib.devShell {
packages = rustPackages ++ [
fenixPkgs.rust-analyzer
# C dev tools
pkgs.gcc
pkgs.llvmPackages_21.clang-tools
];
LD_LIBRARY_PATH = "${pkgs.lib.makeLibraryPath rustPackages}";
}