git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commit45b8676241488c0af5e8cafe6f32f8107bd5f5bd
parente6948d80cb
authorLucas Galante <[email protected]>
date2026-06-15 14:40
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;