file manager
git clone https://git.lucas.co/cce-files.git
Restructure architecture: Introduce asynchronous FsService and decouple blocking IO from pages
src/main.rs | 94 +++------------
src/pages/browse.rs | 149 +++++++----------------
src/pages/preview.rs | 159 ++-----------------------
src/services/fs.rs | 326 +++++++++++++++++++++++++++++++++++++++++++++++++++
src/services/mod.rs | 1 +
5 files changed, 403 insertions(+), 326 deletions(-)
diff --git a/src/main.rs b/src/main.rs
index 9da8044..144c20b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,5 @@
mod pages;
+mod services;
use wayland_client::QueueHandle;
use glyphon::{Attrs, Buffer, FontSystem, Metrics};
@@ -53,6 +54,7 @@ struct FilesystemApp {
just_initialized: bool,
ui_context: clear_ui::context::UiContext,
watcher: Option<notify::RecommendedWatcher>,
+ fs_service: crate::services::fs::FsService,
}
// ── Messages ────────────────────────────────────────────────────────
@@ -97,7 +99,7 @@ impl FilesystemApp {
use std::time::Duration;
let (tx, mut rx) = mpsc::channel::<()>(100);
- let sender_clone = self.sender.clone();
+ let fs_service = self.fs_service.sender.clone();
let path_clone = path.clone();
tokio::spawn(async move {
@@ -105,11 +107,7 @@ impl FilesystemApp {
tokio::time::sleep(Duration::from_millis(150)).await;
while rx.try_recv().is_ok() {}
- let entries = pages::browse::read_directory(&path_clone);
- let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryRefreshed(
- path_clone.clone(),
- entries,
- )));
+ let _ = fs_service.send(crate::services::fs::FsRequest::RefreshDirectory(path_clone.clone())).await;
}
});
@@ -409,6 +407,7 @@ impl Application for FilesystemApp {
.with_sidebar_label("CLEAR")
.with_tabs_rotated(true);
+ let fs_service = crate::services::fs::FsService::new(sender.clone());
let mut app = Self {
current_page: Page::Browse,
browse,
@@ -437,15 +436,11 @@ impl Application for FilesystemApp {
just_initialized: true,
ui_context: clear_ui::context::UiContext::new(),
watcher: None,
+ fs_service,
};
- // Start initial directory loading
- let sender_clone = sender.clone();
- let path_clone = current_dir.clone();
- tokio::spawn(async move {
- let entries = pages::browse::read_directory(&path_clone);
- let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(path_clone, entries)));
- });
+ // Start initial directory loading via FsService
+ app.fs_service.send(crate::services::fs::FsRequest::ReadLastDir);
app.rebuild_layout();
app
@@ -496,36 +491,13 @@ impl Application for FilesystemApp {
false
};
- let is_navigation = match &msg {
- pages::browse::BrowseMessage::NavigateTo(idx) => {
- self.browse.entries.get(*idx).map(|e| e.is_dir && !is_project_dir(&e.path)).unwrap_or(false)
- }
- pages::browse::BrowseMessage::NavigateToPath(_) => true,
- _ => false,
- };
- let is_delete = match &msg {
- pages::browse::BrowseMessage::DeleteEntry(_) => true,
- _ => false,
- };
let is_directory_loaded = match &msg {
pages::browse::BrowseMessage::DirectoryLoaded(path, _) => Some(path.clone()),
_ => None,
};
- let (target_path, handle) = pages::browse::update(&mut self.browse, msg);
- if is_navigation || is_delete {
- let sender_clone = self.sender.clone();
- let is_delete_clone = is_delete;
- let target_path_clone = target_path.clone();
- tokio::spawn(async move {
- let entries = handle.await.unwrap_or_default();
- let response_msg = if is_delete_clone {
- Message::Browse(pages::browse::BrowseMessage::DirectoryRefreshed(target_path_clone, entries))
- } else {
- Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(target_path_clone, entries))
- };
- let _ = sender_clone.send(response_msg);
- });
+ if let Some(req) = pages::browse::update(&mut self.browse, msg) {
+ self.fs_service.send(req);
}
if let Some(path) = is_directory_loaded {
@@ -539,7 +511,7 @@ impl Application for FilesystemApp {
None
};
if let Some(path) = selected_path {
- pages::preview::update(&mut self.preview, pages::preview::PreviewMessage::SetPath { path });
+ self.fs_service.send(crate::services::fs::FsRequest::ReadPreview(path));
} else {
pages::preview::update(&mut self.preview, pages::preview::PreviewMessage::Clear);
}
@@ -594,12 +566,7 @@ impl Application for FilesystemApp {
if !filename.is_empty() {
let path = self.browse.current_dir.join(filename);
if path.is_dir() && !is_project_dir(&path) {
- let sender_clone = self.sender.clone();
- let path_clone = path.clone();
- tokio::spawn(async move {
- let entries = pages::browse::read_directory(&path_clone);
- let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(path_clone, entries)));
- });
+ self.fs_service.send(crate::services::fs::FsRequest::ReadDirectory(path));
} else {
println!("{}", path.display());
std::process::exit(0);
@@ -612,12 +579,7 @@ impl Application for FilesystemApp {
};
if let Some(path) = selected_path {
if path.is_dir() && !is_project_dir(&path) {
- let sender_clone = self.sender.clone();
- let path_clone = path.clone();
- tokio::spawn(async move {
- let entries = pages::browse::read_directory(&path_clone);
- let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(path_clone, entries)));
- });
+ self.fs_service.send(crate::services::fs::FsRequest::ReadDirectory(path));
} else {
println!("{}", path.display());
std::process::exit(0);
@@ -810,12 +772,7 @@ impl Application for FilesystemApp {
}
}
}
- let sender_clone = self.sender.clone();
- let path_clone = target_path.clone();
- tokio::spawn(async move {
- let entries = pages::browse::read_directory(&path_clone);
- let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(path_clone, entries)));
- });
+ self.fs_service.send(crate::services::fs::FsRequest::ReadDirectory(target_path));
*needs_rebuild = true;
self.needs_rebuild = true;
}
@@ -843,12 +800,7 @@ impl Application for FilesystemApp {
}
}
}
- let sender_clone = self.sender.clone();
- let path_clone = target_path.clone();
- tokio::spawn(async move {
- let entries = pages::browse::read_directory(&path_clone);
- let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(path_clone, entries)));
- });
+ self.fs_service.send(crate::services::fs::FsRequest::ReadDirectory(target_path));
changed = true;
}
}
@@ -887,7 +839,7 @@ impl Application for FilesystemApp {
}
let selected_path = Some(entry.path.clone());
if let Some(path) = selected_path {
- pages::preview::update(&mut self.preview, pages::preview::PreviewMessage::SetPath { path });
+ self.fs_service.send(crate::services::fs::FsRequest::ReadPreview(path));
}
}
changed = true;
@@ -913,12 +865,7 @@ impl Application for FilesystemApp {
if has_parent && dbl_idx == 0 {
if let Some(parent) = self.browse.current_dir.parent() {
let parent_path = parent.to_path_buf();
- let sender_clone = self.sender.clone();
- let path_clone = parent_path.clone();
- tokio::spawn(async move {
- let entries = pages::browse::read_directory(&path_clone);
- let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(path_clone, entries)));
- });
+ self.fs_service.send(crate::services::fs::FsRequest::ReadDirectory(parent_path));
changed = true;
}
} else if dbl_idx >= offset {
@@ -926,12 +873,7 @@ impl Application for FilesystemApp {
if let Some(entry) = self.browse.entries.get(entry_idx) {
if entry.is_dir && !is_project_dir(&entry.path) {
let path = entry.path.clone();
- let sender_clone = self.sender.clone();
- let path_clone = path.clone();
- tokio::spawn(async move {
- let entries = pages::browse::read_directory(&path_clone);
- let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(path_clone, entries)));
- });
+ self.fs_service.send(crate::services::fs::FsRequest::ReadDirectory(path));
changed = true;
} else if self.select_mode {
self.browse.save_name_box.text = entry.name.clone();
diff --git a/src/pages/browse.rs b/src/pages/browse.rs
index 595b293..e987ee1 100644
--- a/src/pages/browse.rs
+++ b/src/pages/browse.rs
@@ -31,38 +31,10 @@ pub struct BrowseState {
pub save_name_box: clear_ui::widget::TextBox,
}
-fn get_last_dir_file_path() -> Option<PathBuf> {
- let home = std::env::var("HOME").ok()?;
- let dir = PathBuf::from(home).join(".config").join("cce");
- let _ = fs::create_dir_all(&dir);
- Some(dir.join("cce-filesystem-interface-last-dir.txt"))
-}
-
-fn read_last_dir() -> Option<PathBuf> {
- let path = get_last_dir_file_path()?;
- if path.exists() {
- let content = fs::read_to_string(path).ok()?;
- let trimmed = content.trim();
- if !trimmed.is_empty() {
- let pb = PathBuf::from(trimmed);
- if pb.exists() && pb.is_dir() {
- return Some(pb);
- }
- }
- }
- None
-}
-
-fn save_last_dir(dir: &Path) {
- if let Some(path) = get_last_dir_file_path() {
- let _ = fs::write(path, dir.to_string_lossy().as_bytes());
- }
-}
-
impl Default for BrowseState {
fn default() -> Self {
let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
- let initial_dir = read_last_dir().unwrap_or_else(|| PathBuf::from(home));
+ let initial_dir = PathBuf::from(home);
let mut breadcrumb = Breadcrumb::new();
breadcrumb.set_network_opacity(0.95);
let mut state = Self {
@@ -105,6 +77,8 @@ pub enum BrowseMessage {
DirectoryRefreshed(PathBuf, Vec<DirEntry>),
ToggleHidden,
DeleteEntry(usize),
+ Deleted(PathBuf, Result<(), String>),
+ LastDirLoaded(Option<PathBuf>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -120,48 +94,7 @@ pub fn is_project_dir(path: &Path) -> bool {
// ── Helpers ─────────────────────────────────────────────────────────
pub fn read_directory(path: &Path) -> Vec<DirEntry> {
- let mut entries: Vec<DirEntry> = match fs::read_dir(path) {
- Ok(rd) => rd
- .filter_map(|e| e.ok())
- .filter_map(|e| {
- let meta = e.metadata().ok()?;
- let name = e.file_name().to_string_lossy().to_string();
- let is_dir = meta.is_dir();
- let size = meta.len();
- let permissions = meta.permissions().mode();
- let modified = meta
- .modified()
- .ok()
- .and_then(|t| {
- let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?;
- let datetime =
- chrono::DateTime::from_timestamp(secs.as_secs() as i64, 0)?;
- Some(datetime.format("%Y-%m-%d %H:%M").to_string())
- })
- .unwrap_or_else(|| "—".to_string());
- Some(DirEntry {
- name,
- path: e.path(),
- is_dir,
- size,
- permissions,
- modified,
- })
- })
- .collect(),
- Err(_) => return Vec::new(),
- };
-
- // Sort: directories first, then files; alphabetically within each group
- entries.sort_by(|a, b| {
- match (a.is_dir, b.is_dir) {
- (true, false) => std::cmp::Ordering::Less,
- (false, true) => std::cmp::Ordering::Greater,
- _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
- }
- });
-
- entries
+ crate::services::fs::read_directory_internal(path)
}
fn format_size(size: u64) -> String {
@@ -423,40 +356,36 @@ fn apply_filters(state: &mut BrowseState) {
state.selected = if state.entries.is_empty() { None } else { Some(0) };
}
-pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> (PathBuf, tokio::task::JoinHandle<Vec<DirEntry>>) {
+pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::services::fs::FsRequest> {
match msg {
BrowseMessage::SearchChanged(q) => {
state.search_box.text = q;
apply_filters(state);
- (state.current_dir.clone(), tokio::spawn(async { Vec::new() }))
+ None
}
BrowseMessage::SelectEntry(i) => {
state.selected = Some(i);
- (state.current_dir.clone(), tokio::spawn(async { Vec::new() }))
+ None
}
BrowseMessage::NavigateTo(idx) => {
if let Some(entry) = state.entries.get(idx) {
if entry.is_dir && !is_project_dir(&entry.path) {
- let path = entry.path.clone();
- let p = path.clone();
- return (path, tokio::spawn(async move { read_directory(&p) }));
+ return Some(crate::services::fs::FsRequest::ReadDirectory(entry.path.clone()));
}
}
- (state.current_dir.clone(), tokio::spawn(async { Vec::new() }))
+ None
}
BrowseMessage::NavigateToPath(path) => {
- let p = path.clone();
- (path, tokio::spawn(async move { read_directory(&p) }))
+ Some(crate::services::fs::FsRequest::ReadDirectory(path))
}
BrowseMessage::DirectoryLoaded(path, entries) => {
- state.current_dir = path;
- save_last_dir(&state.current_dir);
+ state.current_dir = path.clone();
state.all_entries = entries;
state.search_box.text.clear();
state.search_box.edit_buffer.clear();
apply_filters(state);
state.update_breadcrumb();
- (state.current_dir.clone(), tokio::spawn(async { Vec::new() }))
+ Some(crate::services::fs::FsRequest::SaveLastDir(path))
}
BrowseMessage::DirectoryRefreshed(path, entries) => {
if state.current_dir == path {
@@ -467,37 +396,43 @@ pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> (PathBuf, tokio::t
state.selected = state.entries.iter().position(|e| e.path == path);
}
}
- (state.current_dir.clone(), tokio::spawn(async { Vec::new() }))
+ None
}
BrowseMessage::ToggleHidden => {
state.show_hidden = !state.show_hidden;
apply_filters(state);
- (state.current_dir.clone(), tokio::spawn(async { Vec::new() }))
+ None
}
BrowseMessage::DeleteEntry(idx) => {
if let Some(entry) = state.entries.get(idx) {
- let path = entry.path.clone();
- let is_dir = entry.is_dir;
- let res = if is_dir {
- std::fs::remove_dir_all(&path)
- } else {
- std::fs::remove_file(&path)
- };
- if let Err(e) = res {
- eprintln!("Failed to delete {}: {:?}", path.display(), e);
- } else {
- // Remove from local entries list immediately for responsive UI
+ Some(crate::services::fs::FsRequest::DeletePath(entry.path.clone(), entry.is_dir))
+ } else {
+ None
+ }
+ }
+ BrowseMessage::Deleted(path, result) => {
+ match result {
+ Ok(_) => {
state.all_entries.retain(|e| e.path != path);
state.entries.retain(|e| e.path != path);
if state.entries.is_empty() {
state.selected = None;
- } else {
+ } else if let Some(idx) = state.selected {
state.selected = Some(idx.min(state.entries.len() - 1));
}
}
+ Err(e) => {
+ eprintln!("Failed to delete {}: {}", path.display(), e);
+ }
}
- let p = state.current_dir.clone();
- (state.current_dir.clone(), tokio::spawn(async move { read_directory(&p) }))
+ Some(crate::services::fs::FsRequest::ReadDirectory(state.current_dir.clone()))
+ }
+ BrowseMessage::LastDirLoaded(last_dir) => {
+ let path = last_dir.unwrap_or_else(|| {
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
+ PathBuf::from(home)
+ });
+ Some(crate::services::fs::FsRequest::ReadDirectory(path))
}
}
}
@@ -625,10 +560,10 @@ mod tests {
let _ = std::fs::create_dir_all(&test_dir);
// Save last directory
- save_last_dir(&test_dir);
+ crate::services::fs::save_last_dir_internal(&test_dir);
// Read last directory
- let restored = read_last_dir();
+ let restored = crate::services::fs::read_last_dir_internal();
assert_eq!(restored, Some(test_dir.clone()));
// Restore HOME env var
@@ -681,12 +616,18 @@ mod tests {
// Assert file exists before deletion
assert!(file_path.exists());
- // Perform update call
- let (_, _handle) = update(&mut state, BrowseMessage::DeleteEntry(0));
+ // Perform update call for DeleteEntry
+ let req = update(&mut state, BrowseMessage::DeleteEntry(0));
+ assert!(matches!(req, Some(crate::services::fs::FsRequest::DeletePath(_, _))));
- // Check if file is deleted from disk
+ // Directly delete the file to simulate the FsService action
+ std::fs::remove_file(&file_path).unwrap();
assert!(!file_path.exists());
+ // Perform update call for Deleted response
+ let req2 = update(&mut state, BrowseMessage::Deleted(file_path.clone(), Ok(())));
+ assert!(matches!(req2, Some(crate::services::fs::FsRequest::ReadDirectory(_))));
+
// Check if state entries are updated
assert!(state.entries.is_empty());
assert!(state.all_entries.is_empty());
diff --git a/src/pages/preview.rs b/src/pages/preview.rs
index 1e20466..d2dbbc7 100644
--- a/src/pages/preview.rs
+++ b/src/pages/preview.rs
@@ -1,5 +1,3 @@
-use std::fs;
-use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use crate::pages::PageContent;
@@ -26,70 +24,10 @@ pub struct PreviewState {
pub enum PreviewMessage {
SetPath { path: PathBuf },
Clear,
+ PreviewLoaded { path: PathBuf, data: crate::services::fs::PreviewData },
}
-// ── Helpers ─────────────────────────────────────────────────────────
-fn format_size(size: u64) -> String {
- if size < 1024 {
- format!("{} B", size)
- } else if size < 1024 * 1024 {
- format!("{:.1} K", size as f64 / 1024.0)
- } else if size < 1024 * 1024 * 1024 {
- format!("{:.1} M", size as f64 / (1024.0 * 1024.0))
- } else {
- format!("{:.1} G", size as f64 / (1024.0 * 1024.0 * 1024.0))
- }
-}
-
-fn format_permissions(mode: u32) -> String {
- let mut s = String::with_capacity(10);
- s.push(if mode & 0o40000 != 0 { 'd' } else { '-' });
- s.push(if mode & 0o400 != 0 { 'r' } else { '-' });
- s.push(if mode & 0o200 != 0 { 'w' } else { '-' });
- s.push(if mode & 0o100 != 0 { 'x' } else { '-' });
- s.push(if mode & 0o040 != 0 { 'r' } else { '-' });
- s.push(if mode & 0o020 != 0 { 'w' } else { '-' });
- s.push(if mode & 0o010 != 0 { 'x' } else { '-' });
- s.push(if mode & 0o004 != 0 { 'r' } else { '-' });
- s.push(if mode & 0o002 != 0 { 'w' } else { '-' });
- s.push(if mode & 0o001 != 0 { 'x' } else { '-' });
- s
-}
-
-fn infer_file_type(name: &str, is_dir: bool) -> String {
- if is_dir {
- return "Directory".to_string();
- }
- match name.rsplit('.').next() {
- Some("rs") => "Rust source".to_string(),
- Some("toml") => "TOML config".to_string(),
- Some("json") => "JSON data".to_string(),
- Some("yaml") | Some("yml") => "YAML config".to_string(),
- Some("png") => "PNG image".to_string(),
- Some("jpg") | Some("jpeg") => "JPEG image".to_string(),
- Some("svg") => "SVG image".to_string(),
- Some("gif") => "GIF image".to_string(),
- Some("mp3") => "MP3 audio".to_string(),
- Some("wav") => "WAV audio".to_string(),
- Some("flac") => "FLAC audio".to_string(),
- Some("mp4") => "MP4 video".to_string(),
- Some("mkv") => "Matroska video".to_string(),
- Some("zip") => "ZIP archive".to_string(),
- Some("tar") => "Tar archive".to_string(),
- Some("gz") => "Gzip archive".to_string(),
- Some("py") => "Python source".to_string(),
- Some("sh") | Some("bash") => "Shell script".to_string(),
- Some("md") => "Markdown".to_string(),
- Some("txt") => "Plain text".to_string(),
- Some("c") | Some("h") => "C source".to_string(),
- Some("cpp") | Some("hpp") | Some("cc") => "C++ source".to_string(),
- Some("hs") => "Haskell source".to_string(),
- Some("exe") => "Windows executable".to_string(),
- Some("pdf") => "PDF document".to_string(),
- _ => "File".to_string(),
- }
-}
impl PreviewState {
pub fn handle_mouse_wheel(&mut self, delta: &clear_ui::widget::MouseScrollDelta, ch: f32) -> bool {
@@ -241,93 +179,22 @@ pub fn update(state: &mut PreviewState, msg: PreviewMessage) {
PreviewMessage::Clear => {
*state = PreviewState::default();
}
- PreviewMessage::SetPath { path } => {
- let meta = fs::symlink_metadata(&path).ok();
- let name = path
- .file_name()
- .map(|n| n.to_string_lossy().to_string())
- .unwrap_or_else(|| path.to_string_lossy().to_string());
-
- let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
- let size = meta.as_ref().map(|m| format_size(m.len())).unwrap_or_else(|| "—".to_string());
- let permissions = meta
- .as_ref()
- .map(|m| format_permissions(m.permissions().mode()))
- .unwrap_or_else(|| "—".to_string());
- let modified = meta
- .as_ref()
- .and_then(|m| m.modified().ok())
- .and_then(|t| {
- let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?;
- let datetime = chrono::DateTime::from_timestamp(secs.as_secs() as i64, 0)?;
- Some(datetime.format("%Y-%m-%d %H:%M:%S").to_string())
- })
- .unwrap_or_else(|| "—".to_string());
-
- let file_type = infer_file_type(&name, is_dir);
-
- // Check symlink target
- let target = if meta.as_ref().map(|m| m.file_type().is_symlink()).unwrap_or(false) {
- fs::read_link(&path)
- .map(|t| t.to_string_lossy().to_string())
- .unwrap_or_default()
- } else {
- String::new()
- };
-
+ PreviewMessage::SetPath { path: _ } => {
+ // Deprecated direct SetPath, as we now load previews via the FsService.
+ }
+ PreviewMessage::PreviewLoaded { path, data } => {
let path_display = path.to_string_lossy().to_string();
-
- // Load content preview
- let content_preview = if is_dir {
- if let Ok(entries) = fs::read_dir(&path) {
- let mut names = Vec::new();
- for entry in entries.flatten().take(100) {
- let name = entry.file_name().to_string_lossy().to_string();
- let is_sub_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
- let icon = if is_sub_dir { "📁" } else { "📄" };
- names.push(format!("{} {}", icon, name));
- }
- if names.is_empty() {
- Some("[Empty directory]".to_string())
- } else {
- Some(names.join("\n"))
- }
- } else {
- None
- }
- } else if let Ok(mut file) = fs::File::open(&path) {
- use std::io::Read;
- let mut buf = vec![0u8; 65536];
- if let Ok(n) = file.read(&mut buf) {
- buf.truncate(n);
- let is_text = match std::str::from_utf8(&buf) {
- Ok(_) => true,
- Err(err) => err.error_len().is_none() && err.valid_up_to() > 0,
- };
- if is_text {
- let utf8_str = String::from_utf8_lossy(&buf).into_owned();
- Some(utf8_str)
- } else {
- Some("[Binary file content]".to_string())
- }
- } else {
- None
- }
- } else {
- None
- };
-
*state = PreviewState {
path: Some(path),
path_display,
- name,
- is_dir,
- size,
- permissions,
- modified,
- file_type,
- target,
- content_preview,
+ name: data.name,
+ is_dir: data.is_dir,
+ size: data.size,
+ permissions: data.permissions,
+ modified: data.modified,
+ file_type: data.file_type,
+ target: data.target,
+ content_preview: data.content_preview,
scroll_line: 0,
};
}
diff --git a/src/services/fs.rs b/src/services/fs.rs
new file mode 100644
index 0000000..0e54179
--- /dev/null
+++ b/src/services/fs.rs
@@ -0,0 +1,326 @@
+use std::fs;
+use std::os::unix::fs::PermissionsExt;
+use std::path::{Path, PathBuf};
+use tokio::sync::mpsc;
+use crate::pages::browse::DirEntry;
+
+#[derive(Debug, Clone, Default)]
+pub struct PreviewData {
+ pub name: String,
+ pub is_dir: bool,
+ pub size: String,
+ pub permissions: String,
+ pub modified: String,
+ pub file_type: String,
+ pub target: String,
+ pub content_preview: Option<String>,
+}
+
+#[derive(Debug, Clone)]
+pub enum FsRequest {
+ ReadDirectory(PathBuf),
+ RefreshDirectory(PathBuf),
+ ReadPreview(PathBuf),
+ DeletePath(PathBuf, bool), // (path, is_dir)
+ ReadLastDir,
+ SaveLastDir(PathBuf),
+}
+
+pub struct FsService {
+ pub sender: mpsc::Sender<FsRequest>,
+}
+
+impl FsService {
+ pub fn new(app_sender: calloop::channel::Sender<crate::Message>) -> Self {
+ let (tx, mut rx) = mpsc::channel::<FsRequest>(100);
+
+ tokio::spawn(async move {
+ while let Some(req) = rx.recv().await {
+ let app_sender = app_sender.clone();
+ match req {
+ FsRequest::ReadDirectory(path) => {
+ tokio::spawn(async move {
+ let entries = read_directory_internal(&path);
+ let _ = app_sender.send(crate::Message::Browse(
+ crate::pages::browse::BrowseMessage::DirectoryLoaded(path, entries),
+ ));
+ });
+ }
+ FsRequest::RefreshDirectory(path) => {
+ tokio::spawn(async move {
+ let entries = read_directory_internal(&path);
+ let _ = app_sender.send(crate::Message::Browse(
+ crate::pages::browse::BrowseMessage::DirectoryRefreshed(path, entries),
+ ));
+ });
+ }
+ FsRequest::ReadPreview(path) => {
+ tokio::spawn(async move {
+ let preview_data = load_preview_data_internal(&path);
+ let _ = app_sender.send(crate::Message::Preview(
+ crate::pages::preview::PreviewMessage::PreviewLoaded { path, data: preview_data },
+ ));
+ });
+ }
+ FsRequest::DeletePath(path, is_dir) => {
+ tokio::spawn(async move {
+ let res = if is_dir {
+ fs::remove_dir_all(&path)
+ } else {
+ fs::remove_file(&path)
+ };
+ let result = res.map_err(|e| e.to_string());
+ let _ = app_sender.send(crate::Message::Browse(
+ crate::pages::browse::BrowseMessage::Deleted(path, result),
+ ));
+ });
+ }
+ FsRequest::ReadLastDir => {
+ tokio::spawn(async move {
+ let last_dir = read_last_dir_internal();
+ let _ = app_sender.send(crate::Message::Browse(
+ crate::pages::browse::BrowseMessage::LastDirLoaded(last_dir),
+ ));
+ });
+ }
+ FsRequest::SaveLastDir(dir) => {
+ tokio::spawn(async move {
+ save_last_dir_internal(&dir);
+ });
+ }
+ }
+ }
+ });
+
+ Self { sender: tx }
+ }
+
+ pub fn send(&self, req: FsRequest) {
+ let sender = self.sender.clone();
+ tokio::spawn(async move {
+ let _ = sender.send(req).await;
+ });
+ }
+}
+
+// ── Internal Helper Functions ───────────────────────────────────────
+
+pub fn read_directory_internal(path: &Path) -> Vec<DirEntry> {
+ let mut entries: Vec<DirEntry> = match fs::read_dir(path) {
+ Ok(rd) => rd
+ .filter_map(|e| e.ok())
+ .filter_map(|e| {
+ let meta = e.metadata().ok()?;
+ let name = e.file_name().to_string_lossy().to_string();
+ let is_dir = meta.is_dir();
+ let size = meta.len();
+ let permissions = meta.permissions().mode();
+ let modified = meta
+ .modified()
+ .ok()
+ .and_then(|t| {
+ let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?;
+ let datetime =
+ chrono::DateTime::from_timestamp(secs.as_secs() as i64, 0)?;
+ Some(datetime.format("%Y-%m-%d %H:%M").to_string())
+ })
+ .unwrap_or_else(|| "—".to_string());
+ Some(DirEntry {
+ name,
+ path: e.path(),
+ is_dir,
+ size,
+ permissions,
+ modified,
+ })
+ })
+ .collect(),
+ Err(_) => return Vec::new(),
+ };
+
+ // Sort: directories first, then files; alphabetically within each group
+ entries.sort_by(|a, b| {
+ match (a.is_dir, b.is_dir) {
+ (true, false) => std::cmp::Ordering::Less,
+ (false, true) => std::cmp::Ordering::Greater,
+ _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
+ }
+ });
+
+ entries
+}
+
+fn load_preview_data_internal(path: &Path) -> PreviewData {
+ let meta = fs::symlink_metadata(path).ok();
+ let name = path
+ .file_name()
+ .map(|n| n.to_string_lossy().to_string())
+ .unwrap_or_else(|| path.to_string_lossy().to_string());
+
+ let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
+ let size = meta.as_ref().map(|m| format_size(m.len())).unwrap_or_else(|| "—".to_string());
+ let permissions = meta
+ .as_ref()
+ .map(|m| format_permissions(m.permissions().mode()))
+ .unwrap_or_else(|| "—".to_string());
+ let modified = meta
+ .as_ref()
+ .and_then(|m| m.modified().ok())
+ .and_then(|t| {
+ let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?;
+ let datetime = chrono::DateTime::from_timestamp(secs.as_secs() as i64, 0)?;
+ Some(datetime.format("%Y-%m-%d %H:%M:%S").to_string())
+ })
+ .unwrap_or_else(|| "—".to_string());
+
+ let file_type = infer_file_type(&name, is_dir);
+
+ // Check symlink target
+ let target = if meta.as_ref().map(|m| m.file_type().is_symlink()).unwrap_or(false) {
+ fs::read_link(path)
+ .map(|t| t.to_string_lossy().to_string())
+ .unwrap_or_default()
+ } else {
+ String::new()
+ };
+
+ // Load content preview
+ let content_preview = if is_dir {
+ if let Ok(entries) = fs::read_dir(path) {
+ let mut names = Vec::new();
+ for entry in entries.flatten().take(100) {
+ let name = entry.file_name().to_string_lossy().to_string();
+ let is_sub_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
+ let icon = if is_sub_dir { "📁" } else { "📄" };
+ names.push(format!("{} {}", icon, name));
+ }
+ if names.is_empty() {
+ Some("[Empty directory]".to_string())
+ } else {
+ Some(names.join("\n"))
+ }
+ } else {
+ None
+ }
+ } else if let Ok(mut file) = fs::File::open(path) {
+ use std::io::Read;
+ let mut buf = vec![0u8; 65536];
+ if let Ok(n) = file.read(&mut buf) {
+ buf.truncate(n);
+ let is_text = match std::str::from_utf8(&buf) {
+ Ok(_) => true,
+ Err(err) => err.error_len().is_none() && err.valid_up_to() > 0,
+ };
+ if is_text {
+ let utf8_str = String::from_utf8_lossy(&buf).into_owned();
+ Some(utf8_str)
+ } else {
+ Some("[Binary file content]".to_string())
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
+ PreviewData {
+ name,
+ is_dir,
+ size,
+ permissions,
+ modified,
+ file_type,
+ target,
+ content_preview,
+ }
+}
+
+fn format_size(size: u64) -> String {
+ if size < 1024 {
+ format!("{} B", size)
+ } else if size < 1024 * 1024 {
+ format!("{:.1} K", size as f64 / 1024.0)
+ } else if size < 1024 * 1024 * 1024 {
+ format!("{:.1} M", size as f64 / (1024.0 * 1024.0))
+ } else {
+ format!("{:.1} G", size as f64 / (1024.0 * 1024.0 * 1024.0))
+ }
+}
+
+fn format_permissions(mode: u32) -> String {
+ let mut s = String::with_capacity(10);
+ s.push(if mode & 0o40000 != 0 { 'd' } else { '-' });
+ s.push(if mode & 0o400 != 0 { 'r' } else { '-' });
+ s.push(if mode & 0o200 != 0 { 'w' } else { '-' });
+ s.push(if mode & 0o100 != 0 { 'x' } else { '-' });
+ s.push(if mode & 0o040 != 0 { 'r' } else { '-' });
+ s.push(if mode & 0o020 != 0 { 'w' } else { '-' });
+ s.push(if mode & 0o010 != 0 { 'x' } else { '-' });
+ s.push(if mode & 0o004 != 0 { 'r' } else { '-' });
+ s.push(if mode & 0o002 != 0 { 'w' } else { '-' });
+ s.push(if mode & 0o001 != 0 { 'x' } else { '-' });
+ s
+}
+
+fn infer_file_type(name: &str, is_dir: bool) -> String {
+ if is_dir {
+ return "Directory".to_string();
+ }
+ match name.rsplit('.').next() {
+ Some("rs") => "Rust source".to_string(),
+ Some("toml") => "TOML config".to_string(),
+ Some("json") => "JSON data".to_string(),
+ Some("yaml") | Some("yml") => "YAML config".to_string(),
+ Some("png") => "PNG image".to_string(),
+ Some("jpg") | Some("jpeg") => "JPEG image".to_string(),
+ Some("svg") => "SVG image".to_string(),
+ Some("gif") => "GIF image".to_string(),
+ Some("mp3") => "MP3 audio".to_string(),
+ Some("wav") => "WAV audio".to_string(),
+ Some("flac") => "FLAC audio".to_string(),
+ Some("mp4") => "MP4 video".to_string(),
+ Some("mkv") => "Matroska video".to_string(),
+ Some("zip") => "ZIP archive".to_string(),
+ Some("tar") => "Tar archive".to_string(),
+ Some("gz") => "Gzip archive".to_string(),
+ Some("py") => "Python source".to_string(),
+ Some("sh") | Some("bash") => "Shell script".to_string(),
+ Some("md") => "Markdown".to_string(),
+ Some("txt") => "Plain text".to_string(),
+ Some("c") | Some("h") => "C source".to_string(),
+ Some("cpp") | Some("hpp") | Some("cc") => "C++ source".to_string(),
+ Some("hs") => "Haskell source".to_string(),
+ Some("exe") => "Windows executable".to_string(),
+ Some("pdf") => "PDF document".to_string(),
+ _ => "File".to_string(),
+ }
+}
+
+fn get_last_dir_file_path() -> Option<PathBuf> {
+ let home = std::env::var("HOME").ok()?;
+ let dir = PathBuf::from(home).join(".config").join("cce");
+ let _ = fs::create_dir_all(&dir);
+ Some(dir.join("cce-filesystem-interface-last-dir.txt"))
+}
+
+pub fn read_last_dir_internal() -> Option<PathBuf> {
+ let path = get_last_dir_file_path()?;
+ if path.exists() {
+ let content = fs::read_to_string(path).ok()?;
+ let trimmed = content.trim();
+ if !trimmed.is_empty() {
+ let pb = PathBuf::from(trimmed);
+ if pb.exists() && pb.is_dir() {
+ return Some(pb);
+ }
+ }
+ }
+ None
+}
+
+pub fn save_last_dir_internal(dir: &Path) {
+ if let Some(path) = get_last_dir_file_path() {
+ let _ = fs::write(path, dir.to_string_lossy().as_bytes());
+ }
+}
diff --git a/src/services/mod.rs b/src/services/mod.rs
new file mode 100644
index 0000000..d521fbd
--- /dev/null
+++ b/src/services/mod.rs
@@ -0,0 +1 @@
+pub mod fs;