diff --git a/src/backend/impl_tui.rs b/src/backend/impl_tui.rs index 4c3917f..3d07231 100644 --- a/src/backend/impl_tui.rs +++ b/src/backend/impl_tui.rs @@ -98,27 +98,32 @@ impl BackEnd { Err(e) => CallBack::Error(e.to_string()), } } + Call::Service(tabs::service::BackendOp::OpenThis(path)) => { + if let Err(e) = crate::utils::ipc::spawn( + "sh", + vec![ + "-c", + if path.is_dir() { + &self.open_dir_cmd + } else { + &self.edit_cmd + } + .replace("%s", path.to_str().unwrap()) + .as_str(), + ], + ) { + CallBack::TuiExtend(vec!["Failed".to_owned(), e.to_string()]) + } else { + CallBack::TuiExtend(vec!["Success".to_owned()]) + } + } Call::Service(tabs::service::BackendOp::TuiExtend(extend_op)) => { match extend_op { tabs::service::ExtendOp::FullLog => match self.logcat(0, 1024) { Ok(v) => CallBack::TuiExtend(v), Err(e) => CallBack::Error(e.to_string()), }, - tabs::service::ExtendOp::OpenClashtuiConfigDir => { - if let Err(e) = crate::utils::ipc::spawn( - "sh", - vec![ - "-c", - self.open_dir_cmd - .replace("%s", crate::HOME_DIR.to_str().unwrap()) - .as_str(), - ], - ) { - CallBack::TuiExtend(vec!["Failed".to_owned(), e.to_string()]) - } else { - CallBack::TuiExtend(vec!["Success".to_owned()]) - } - } + tabs::service::ExtendOp::ViewClashtuiConfigDir => unreachable!(), tabs::service::ExtendOp::GenerateInfoList => { let mut infos = vec![ "# CLASHTUI".to_owned(), diff --git a/src/tui/frontend/tabs/service.rs b/src/tui/frontend/tabs/service.rs index 200b9de..f0492dc 100644 --- a/src/tui/frontend/tabs/service.rs +++ b/src/tui/frontend/tabs/service.rs @@ -1,5 +1,6 @@ use crate::backend::ServiceOp; use crate::define_enum; +use crate::tui::widget::Browser; use crate::{clash::webapi::Mode, tui::widget::PopRes}; use crossterm::event::KeyEvent; @@ -14,12 +15,13 @@ pub enum BackendOp { SwitchMode(Mode), ServiceCTL(ServiceOp), TuiExtend(ExtendOp), + OpenThis(std::path::PathBuf), } define_enum!( #[derive(Clone, Copy, Debug)] pub enum ExtendOp { - OpenClashtuiConfigDir, + ViewClashtuiConfigDir, // Generate a list of information // about the application and clash core GenerateInfoList, @@ -31,6 +33,7 @@ pub(in crate::tui::frontend) struct ServiceTab { inner: List, popup_content: Option, backend_content: Option, + file_browser: Option, } const MODE: [Mode; 3] = [Mode::Rule, Mode::Direct, Mode::Global]; @@ -49,6 +52,7 @@ impl Default for ServiceTab { inner, popup_content: None, backend_content: None, + file_browser: None, } } } @@ -100,11 +104,26 @@ impl TabCont for ServiceTab { impl Drawable for ServiceTab { fn render(&mut self, f: &mut Frame, area: Rect, _: bool) { - self.inner.render(f, area, true); + self.inner.render(f, area, self.file_browser.is_none()); + if let Some(instance) = self.file_browser.as_mut() { + instance.render(f, area, true); + } } /// - Catched event -> [EventState::WorkDone] /// - unrecognized event -> [EventState::NotConsumed] fn handle_key_event(&mut self, ev: &KeyEvent) -> EventState { + if let Some(instance) = self.file_browser.as_mut() { + match instance.handle_key_event(ev) { + EventState::Yes => { + self.backend_content = Some(Call::Service(BackendOp::OpenThis( + self.file_browser.take().unwrap().path(), + ))) + } + EventState::Cancel => self.file_browser = None, + EventState::NotConsumed | EventState::WorkDone => (), + } + return EventState::WorkDone; + }; let event_state = self.inner.handle_key_event(ev); match event_state { EventState::Yes => { @@ -124,8 +143,14 @@ impl Drawable for ServiceTab { idx if idx == ServiceOp::const_len() + 1 => (), idx if idx < ServiceOp::const_len() + 2 + ExtendOp::const_len() => { let op = ExtendOp::ALL[index - 2 - ServiceOp::const_len()]; - self.backend_content = Some(Call::Service(BackendOp::TuiExtend(op))); - self.popup_content = Some(PopMsg::Prompt(vec!["working".to_owned()])); + if let ExtendOp::ViewClashtuiConfigDir = op { + self.file_browser = Some(Browser::new(&crate::HOME_DIR)) + } else { + self.backend_content = + Some(Call::Service(BackendOp::TuiExtend(op))); + self.popup_content = + Some(PopMsg::Prompt(vec!["working".to_owned()])); + } } _ => unreachable!(), } diff --git a/src/tui/widget/browser.rs b/src/tui/widget/browser.rs new file mode 100644 index 0000000..c0cd743 --- /dev/null +++ b/src/tui/widget/browser.rs @@ -0,0 +1,187 @@ +use std::path::PathBuf; + +use crossterm::event::{KeyCode, KeyEventKind}; +use ratatui::{prelude as Ra, widgets as Raw}; + +use super::tools; +use crate::tui::misc::EventState; +use crate::tui::{Drawable, Theme}; + +pub struct Browser { + cwd: PathBuf, + items: Vec>, + selected: usize, +} + +impl Drawable for Browser { + fn render(&mut self, f: &mut ratatui::Frame, _: ratatui::layout::Rect, _: bool) { + let area = tools::centered_rect(Ra::Constraint::Fill(2), Ra::Constraint::Fill(2), f.area()); + + let mut state = Raw::ListState::default().with_selected(Some(self.selected)); + + let list = Raw::List::new(self.items.iter().map(|file| { + Ra::Text::raw(file.name()).style(Ra::Style::default().fg(if file.is_dir() { + Ra::Color::LightCyan + } else { + Ra::Color::default() + })) + })) + .scroll_padding((area.height - 3).div_ceil(2) as usize) + .highlight_style( + Ra::Style::default() + .bg(Theme::get().list_hl_bg_fouced) + .add_modifier(Ra::Modifier::BOLD), + ) + .block( + Raw::Block::bordered() + .border_style(Ra::Style::default().fg(Theme::get().list_block_fouced_fg)) + .title_top(format!("Browser: {}", self.cwd.display())) + .title_bottom(Ra::Line::raw("Press 'O' to open selected dir/file").right_aligned()), + ); + + f.render_widget(Raw::Clear, area); + f.render_stateful_widget(list, area, &mut state); + } + + fn handle_key_event(&mut self, ev: &crossterm::event::KeyEvent) -> EventState { + if ev.kind != KeyEventKind::Press { + return EventState::NotConsumed; + } + match ev.code { + KeyCode::Right | KeyCode::Enter => { + if self.items[self.selected].is_dir() { + self.cwd = self.items.swap_remove(self.selected).path(); + self.update(); + } else { + return EventState::Yes; + } + } + KeyCode::Left | KeyCode::Backspace => { + if self.items[0].name() == UPPER_DIR { + self.cwd = self.items.swap_remove(0).path(); + self.update(); + } + } + + KeyCode::Up => self.selected = self.selected.saturating_sub(1), + KeyCode::Down => self.selected = (self.selected + 1).min(self.items.len() - 1), + + KeyCode::Home => self.selected = 0, + KeyCode::End => self.selected = self.items.len() - 1, + + KeyCode::Char('O') | KeyCode::Char('o') => return EventState::Yes, + KeyCode::Esc => return EventState::Cancel, + _ => (), + }; + EventState::WorkDone + } +} + +impl Browser { + pub fn new(path: &std::path::Path) -> Self { + let mut instance = Self { + cwd: path.to_path_buf(), + items: vec![], + selected: 0, + }; + instance.update(); + instance + } + pub fn path(mut self) -> std::path::PathBuf { + self.items.swap_remove(self.selected).path() + } + fn update(&mut self) { + if let Err(e) = self.get_and_set_files() { + let err = format!("Failed to open {}: {e}", self.cwd.display()); + log::error!("{err}"); + self.items = vec![Box::new(FsErr(err))]; + }; + self.selected = 0 + } + fn get_and_set_files(&mut self) -> std::io::Result<()> { + let (mut dirs, mut none_dirs): (Vec<_>, Vec<_>) = std::fs::read_dir(&self.cwd)? + .filter_map(|entry| entry.ok()) + .map(|e| { + let path = e.path(); + Box::new(FileOrDir(path)).to_dyn() + }) + .partition(|file| file.is_dir()); + + dirs.sort_unstable_by(|f1, f2| f1.name().cmp(&f2.name())); + none_dirs.sort_unstable_by(|f1, f2| f1.name().cmp(&f2.name())); + + if let Some(parent) = self.cwd.parent() { + let mut files: Vec> = Vec::with_capacity(1 + dirs.len() + none_dirs.len()); + + files.push(Box::new(UpperDir(parent.to_path_buf()))); + + files.extend(dirs); + files.extend(none_dirs); + + self.items = files + } else { + let mut files = Vec::with_capacity(dirs.len() + none_dirs.len()); + + files.extend(dirs); + files.extend(none_dirs); + + self.items = files; + }; + + Ok(()) + } +} + +const UPPER_DIR: &str = "../"; +trait Fp: Send { + fn name(&self) -> std::borrow::Cow<'_, str>; + fn is_dir(&self) -> bool; + fn path(self: Box) -> std::path::PathBuf; + #[inline] + fn to_dyn(self: Box) -> Box + where + Self: Sized + 'static, + { + self + } +} +#[derive(Debug)] +struct FileOrDir(PathBuf); +impl Fp for FileOrDir { + /// the path is built from [std::fs::DirEntry] + /// thus it will never end in '.','..' + fn name(&self) -> std::borrow::Cow<'_, str> { + self.0.file_name().unwrap().to_string_lossy() + } + fn is_dir(&self) -> bool { + self.0.is_dir() + } + fn path(self: Box) -> std::path::PathBuf { + self.0 + } +} +struct UpperDir(PathBuf); +impl Fp for UpperDir { + fn name(&self) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Borrowed(UPPER_DIR) + } + fn is_dir(&self) -> bool { + self.0.is_dir() + } + fn path(self: Box) -> std::path::PathBuf { + self.0 + } +} +struct FsErr(String); +impl Fp for FsErr { + fn name(&self) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Borrowed(&self.0) + } + fn is_dir(&self) -> bool { + true + } + /// current, just redirect to temp dir + fn path(self: Box) -> std::path::PathBuf { + std::env::temp_dir() + } +} diff --git a/src/tui/widget/mod.rs b/src/tui/widget/mod.rs index ef0f597..afc2ec3 100644 --- a/src/tui/widget/mod.rs +++ b/src/tui/widget/mod.rs @@ -1,8 +1,10 @@ +mod browser; mod input; mod list; mod popup; pub mod tools; +pub use browser::Browser; pub use list::List; pub use popup::Popup;