Seems to show things
This commit is contained in:
parent
d4f42ec6aa
commit
66c3ef49ac
13 changed files with 4239 additions and 2 deletions
19
.direnv/bin/nix-direnv-reload
Executable file
19
.direnv/bin/nix-direnv-reload
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env bash
|
||||
set -e
|
||||
if [[ ! -d "/mnt/removable/Projects/Rust/file-explorer" ]]; then
|
||||
echo "Cannot find source directory; Did you move it?"
|
||||
echo "(Looking for "/mnt/removable/Projects/Rust/file-explorer")"
|
||||
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# rebuild the cache forcefully
|
||||
_nix_direnv_force_reload=1 direnv exec "/mnt/removable/Projects/Rust/file-explorer" true
|
||||
|
||||
# Update the mtime for .envrc.
|
||||
# This will cause direnv to reload again - but without re-building.
|
||||
touch "/mnt/removable/Projects/Rust/file-explorer/.envrc"
|
||||
|
||||
# Also update the timestamp of whatever profile_rc we have.
|
||||
# This makes sure that we know we are up to date.
|
||||
touch -r "/mnt/removable/Projects/Rust/file-explorer/.envrc" "/mnt/removable/Projects/Rust/file-explorer/.direnv"/*.rc
|
||||
1
.direnv/flake-inputs/8hx8jwa24q6rkzhca9iqy5ckk9rbphi1-source
Symbolic link
1
.direnv/flake-inputs/8hx8jwa24q6rkzhca9iqy5ckk9rbphi1-source
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/nix/store/8hx8jwa24q6rkzhca9iqy5ckk9rbphi1-source
|
||||
1
.direnv/flake-inputs/a6vm6qzra5vqs1jy9k2m10fmda3pislg-source
Symbolic link
1
.direnv/flake-inputs/a6vm6qzra5vqs1jy9k2m10fmda3pislg-source
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/nix/store/a6vm6qzra5vqs1jy9k2m10fmda3pislg-source
|
||||
1
.direnv/flake-inputs/b5cw92ch3f64g524j7m5w9jajyadjdi4-source
Symbolic link
1
.direnv/flake-inputs/b5cw92ch3f64g524j7m5w9jajyadjdi4-source
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/nix/store/b5cw92ch3f64g524j7m5w9jajyadjdi4-source
|
||||
1
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
Symbolic link
1
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/nix/store/swz4b263qq4jdxs4fk62cnrg1v7jvmgr-nix-shell-env
|
||||
2182
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
Normal file
2182
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
Normal file
File diff suppressed because it is too large
Load diff
1512
Cargo.lock
generated
Normal file
1512
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
10
Cargo.toml
10
Cargo.toml
|
|
@ -4,3 +4,13 @@ version = "0.1.0"
|
|||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
crossterm = "0.29.0"
|
||||
ratatui = "0.30.0"
|
||||
rustix = { version = "1.1.3", features = ["fs"] }
|
||||
|
||||
# Read the optimization guideline for more details: https://ratatui.rs/recipes/apps/release-your-app/#optimizations
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = 3
|
||||
strip = true
|
||||
|
|
|
|||
25
src/config.rs
Normal file
25
src/config.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
pub struct Config {
|
||||
pub priorities: Priorities,
|
||||
pub show_hidden: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
priorities: Priorities {
|
||||
hidden_dir: 1,
|
||||
dir: 0,
|
||||
hidden_file: 3,
|
||||
file: 2,
|
||||
},
|
||||
show_hidden: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Priorities {
|
||||
pub hidden_dir: u8,
|
||||
pub dir: u8,
|
||||
pub hidden_file: u8,
|
||||
pub file: u8,
|
||||
}
|
||||
28
src/directory_operations.rs
Normal file
28
src/directory_operations.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use crate::config::Config;
|
||||
use rustix::fs::Access;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn sort_dir(config: &Config, files: &mut Vec<PathBuf>) {
|
||||
if !config.show_hidden {
|
||||
files.retain(|file| !is_hidden(file.as_path()));
|
||||
}
|
||||
|
||||
files.sort();
|
||||
files.sort_by_key(|path| match (path.is_dir(), is_hidden(path.as_path())) {
|
||||
(false, false) => config.priorities.file,
|
||||
(false, true) => config.priorities.hidden_file,
|
||||
(true, false) => config.priorities.dir,
|
||||
(true, true) => config.priorities.hidden_dir,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn can_read(path: &Path) -> bool {
|
||||
rustix::fs::access(path, Access::READ_OK).is_ok()
|
||||
}
|
||||
|
||||
fn is_hidden(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|f| f.as_encoded_bytes().first())
|
||||
.map(|&first_byte| first_byte == b'.')
|
||||
.unwrap_or(false)
|
||||
}
|
||||
189
src/file_select.rs
Normal file
189
src/file_select.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::Line,
|
||||
widgets::{StatefulWidget, Widget},
|
||||
};
|
||||
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
env, fs, io, mem,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use crate::{config::Config, directory_operations};
|
||||
|
||||
pub enum ScrollingDirection {
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
pub struct FilesWidget;
|
||||
pub struct FilesState {
|
||||
selected: Option<usize>,
|
||||
top: usize,
|
||||
files: Vec<PathBuf>,
|
||||
current_dir: PathBuf,
|
||||
config: Rc<RefCell<Config>>,
|
||||
}
|
||||
|
||||
impl FilesState {
|
||||
pub fn get_current(&self) -> Option<&Path> {
|
||||
Some(self.files[self.selected?].as_path())
|
||||
}
|
||||
|
||||
pub fn scroll(&mut self, direction: ScrollingDirection) {
|
||||
if self.files.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match direction {
|
||||
ScrollingDirection::Down => self.scroll_down(),
|
||||
ScrollingDirection::Up => self.scroll_up(),
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self) {
|
||||
match self.selected {
|
||||
Some(v) => self.selected = Some((v + 1).min(self.files.len() - 1)),
|
||||
None => self.selected = Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self) {
|
||||
self.selected = Some(self.selected.unwrap_or(0).saturating_sub(1));
|
||||
}
|
||||
|
||||
pub fn go_up(&mut self) -> io::Result<()> {
|
||||
let new_path = self.current_dir.parent().unwrap_or(Path::new("/"));
|
||||
if !directory_operations::can_read(new_path) {
|
||||
return Ok(());
|
||||
}
|
||||
self.current_dir.pop();
|
||||
self.update_contents()
|
||||
}
|
||||
|
||||
fn update_contents(&mut self) -> io::Result<()> {
|
||||
self.files = fs::read_dir(&self.current_dir)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.path())
|
||||
.collect();
|
||||
|
||||
directory_operations::sort_dir(&self.config.borrow(), &mut self.files);
|
||||
|
||||
self.selected = if self.files.is_empty() { None } else { Some(0) };
|
||||
self.top = 0;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn go_inner(&mut self) -> io::Result<()> {
|
||||
let Some(selected) = self.selected else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if !self.files[selected].is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !directory_operations::can_read(self.files[selected].as_path()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.current_dir = self.files.swap_remove(selected);
|
||||
self.update_contents()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn toggle_hidden(&mut self) -> io::Result<()> {
|
||||
let current_state = self.config.borrow().show_hidden;
|
||||
self.set_hidden(!current_state)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_hidden(&mut self, state: bool) -> io::Result<()> {
|
||||
{
|
||||
let mut cfg = self.config.borrow_mut();
|
||||
cfg.show_hidden = state;
|
||||
}
|
||||
|
||||
self.update_contents()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn new(config: Rc<RefCell<Config>>) -> io::Result<Self> {
|
||||
let current_dir = env::current_dir()?;
|
||||
|
||||
let mut output = FilesState {
|
||||
selected: None,
|
||||
files: vec![],
|
||||
top: 0,
|
||||
current_dir,
|
||||
config,
|
||||
};
|
||||
|
||||
output.update_contents()?;
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
impl StatefulWidget for FilesWidget {
|
||||
type State = FilesState;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(area);
|
||||
|
||||
let visible_rows = layout[0].height as usize;
|
||||
|
||||
if let Some(selected) = state.selected {
|
||||
if selected < state.top {
|
||||
state.top = selected;
|
||||
} else if selected >= state.top + visible_rows {
|
||||
state.top = selected - visible_rows + 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut items: Vec<Line> = state
|
||||
.files
|
||||
.iter()
|
||||
.skip(state.top)
|
||||
.take(visible_rows)
|
||||
.map(|file| {
|
||||
Line::default().spans(vec![
|
||||
" ",
|
||||
file.file_name()
|
||||
.expect("Files should have a valid name")
|
||||
.to_str()
|
||||
.expect("Files should have a valid name"),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let relative_selected = state.selected.map(|s| s - state.top);
|
||||
|
||||
if let Some(index) = relative_selected
|
||||
&& let Some(item) = items.get_mut(index)
|
||||
{
|
||||
let mut old = mem::take(item);
|
||||
|
||||
old = old.patch_style(Style::default().fg(Color::Blue));
|
||||
old.spans[0] = "> ".into();
|
||||
|
||||
*item = old;
|
||||
}
|
||||
|
||||
let rows_layout =
|
||||
Layout::vertical(vec![Constraint::Length(1); visible_rows]).split(layout[0]);
|
||||
for (item, area) in items.into_iter().zip(rows_layout.iter()) {
|
||||
item.render(*area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/main.rs
112
src/main.rs
|
|
@ -1,3 +1,111 @@
|
|||
fn main() {
|
||||
println!("Hello, world!");
|
||||
mod config;
|
||||
mod directory_operations;
|
||||
mod file_select;
|
||||
mod preview;
|
||||
|
||||
use config::Config;
|
||||
use file_select::{FilesState, FilesWidget, ScrollingDirection};
|
||||
use preview::{PreviewState, PreviewWidget};
|
||||
use ratatui::{
|
||||
DefaultTerminal, Frame,
|
||||
crossterm::event::{self, Event, KeyCode, KeyEventKind},
|
||||
layout::{Constraint, Direction, Layout},
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::io;
|
||||
use std::rc::Rc;
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let mut terminal = ratatui::init();
|
||||
let app_result = App::new()?.run(&mut terminal);
|
||||
|
||||
ratatui::restore();
|
||||
app_result
|
||||
}
|
||||
|
||||
struct App {
|
||||
files: FilesState,
|
||||
preview: PreviewState,
|
||||
exit: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new() -> io::Result<Self> {
|
||||
let config = Rc::new(RefCell::new(Config::new()));
|
||||
let files = FilesState::new(Rc::clone(&config))?;
|
||||
let mut preview = PreviewState::new(Rc::clone(&config));
|
||||
|
||||
preview.change_preview(files.get_current());
|
||||
|
||||
Ok(Self {
|
||||
files,
|
||||
preview,
|
||||
exit: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
while !self.exit {
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
let _ = self.handle_events();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_events(&mut self) -> io::Result<()> {
|
||||
let Event::Key(current_event) = event::read()? else {
|
||||
return Ok(());
|
||||
};
|
||||
if current_event.kind != KeyEventKind::Press {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match current_event.code {
|
||||
KeyCode::Esc => {
|
||||
self.exit = true;
|
||||
}
|
||||
|
||||
key @ (KeyCode::Down | KeyCode::Up) => {
|
||||
self.files.scroll(match key {
|
||||
KeyCode::Down => ScrollingDirection::Down,
|
||||
KeyCode::Up => ScrollingDirection::Up,
|
||||
_ => unreachable!(),
|
||||
});
|
||||
|
||||
self.preview.change_preview(self.files.get_current());
|
||||
}
|
||||
|
||||
KeyCode::Left => {
|
||||
self.files.go_up()?;
|
||||
self.preview.change_preview(self.files.get_current());
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.files.go_inner()?;
|
||||
self.preview.change_preview(self.files.get_current());
|
||||
}
|
||||
KeyCode::Char('h') => {
|
||||
self.files.toggle_hidden()?;
|
||||
self.preview.change_preview(self.files.get_current());
|
||||
}
|
||||
|
||||
_ => (),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
let main_area = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(frame.area());
|
||||
|
||||
if frame.area().width < 10 || frame.area().height < 30 {
|
||||
self.exit = true;
|
||||
return;
|
||||
}
|
||||
|
||||
frame.render_stateful_widget(FilesWidget, main_area[0], &mut self.files);
|
||||
frame.render_stateful_widget(PreviewWidget, main_area[1], &mut self.preview);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
160
src/preview.rs
Normal file
160
src/preview.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
use ratatui::{
|
||||
layout::{Constraint, Layout},
|
||||
text::Line,
|
||||
widgets::{Paragraph, StatefulWidget, Widget},
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{cell::RefCell, fs, rc::Rc};
|
||||
|
||||
use crate::{config::Config, directory_operations};
|
||||
|
||||
pub struct PreviewWidget;
|
||||
enum PreviewType {
|
||||
Nothing,
|
||||
AccessDenied,
|
||||
Image(String),
|
||||
Pdf(String),
|
||||
Binary(String),
|
||||
Directory(Vec<String>),
|
||||
Text { filename: String, content: String },
|
||||
}
|
||||
pub struct PreviewState {
|
||||
preview_type: PreviewType,
|
||||
config: Rc<RefCell<Config>>,
|
||||
}
|
||||
|
||||
impl PreviewState {
|
||||
pub fn new(config: Rc<RefCell<Config>>) -> Self {
|
||||
Self {
|
||||
preview_type: PreviewType::Nothing,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn change_preview(&mut self, path: Option<&Path>) {
|
||||
let Some(path) = path else {
|
||||
self.preview_type = PreviewType::Nothing;
|
||||
return;
|
||||
};
|
||||
|
||||
if !path.exists() {
|
||||
self.preview_type = PreviewType::Nothing;
|
||||
return;
|
||||
}
|
||||
|
||||
if !directory_operations::can_read(path) {
|
||||
self.preview_type = PreviewType::AccessDenied;
|
||||
return;
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
match fs::read_dir(path) {
|
||||
Ok(entries) => {
|
||||
let mut items: Vec<PathBuf> = entries
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.collect();
|
||||
|
||||
directory_operations::sort_dir(&self.config.borrow(), &mut items);
|
||||
|
||||
self.preview_type = PreviewType::Directory(
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.file_name().map(|f| {
|
||||
f.to_str()
|
||||
.expect("Files should have a valid name")
|
||||
.to_owned()
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
self.preview_type = PreviewType::Nothing;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_lowercase());
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
match extension.as_deref() {
|
||||
Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("bmp") | Some("webp")
|
||||
| Some("svg") | Some("ico") => {
|
||||
self.preview_type = PreviewType::Image(filename);
|
||||
}
|
||||
|
||||
Some("pdf") => {
|
||||
self.preview_type = PreviewType::Pdf(filename);
|
||||
}
|
||||
|
||||
_ => {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
self.preview_type = PreviewType::Text { filename, content };
|
||||
} else {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
self.preview_type = PreviewType::Binary(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatefulWidget for PreviewWidget {
|
||||
type State = PreviewState;
|
||||
|
||||
fn render(
|
||||
self,
|
||||
area: ratatui::prelude::Rect,
|
||||
buf: &mut ratatui::prelude::Buffer,
|
||||
state: &mut Self::State,
|
||||
) {
|
||||
match &state.preview_type {
|
||||
PreviewType::Nothing => Line::from("Nothing selected").render(area, buf),
|
||||
PreviewType::Text { filename, content } => {
|
||||
let areas =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).split(area);
|
||||
|
||||
Paragraph::new(content.as_str()).render(areas[1], buf);
|
||||
Line::from(format!("text file: {}", filename).as_str()).render(areas[0], buf);
|
||||
}
|
||||
PreviewType::Image(name) | PreviewType::Pdf(name) => {
|
||||
Paragraph::new(format!("{}: I need to implement this", name).as_str())
|
||||
.render(area, buf);
|
||||
}
|
||||
PreviewType::Directory(files) => {
|
||||
let visible_rows = area.height as usize;
|
||||
let constraints =
|
||||
Layout::vertical(vec![Constraint::Length(1); visible_rows]).split(area);
|
||||
|
||||
for (file, constraint) in files.iter().zip(constraints.iter()) {
|
||||
Line::from(file.as_str()).render(*constraint, buf);
|
||||
}
|
||||
}
|
||||
PreviewType::Binary(name) => {
|
||||
Paragraph::new(format!("unsupported file type: {}", name)).render(area, buf)
|
||||
}
|
||||
PreviewType::AccessDenied => {
|
||||
Paragraph::new("Access DENIED").render(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue