feat: página Markdown com explorador de ficheiros e preview

- Calendar movido para dentro da página Tasks (já não global)
- Página Markdown: 30% file explorer + 70% preview
- File explorer lista .md do diretório ~/dev/notes (configurável)
- Ctrl+O altera diretório markdown
- Ctrl+P Page Switcher navega entre Tasks e Markdown
- Up/Down no file explorer carrega preview automaticamente
- Focus::FileExplorer + Focus::MarkdownPreview no ciclo Tab
- Bump ratatui 0.28 → 0.30; adicionado tui-markdown 0.3.7
This commit is contained in:
2026-06-26 15:06:42 +01:00
parent a0e2aeca63
commit fb74d29e3a
7 changed files with 1587 additions and 128 deletions
Generated
+1330 -77
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -4,7 +4,8 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
ratatui = "0.28" ratatui = "0.30"
tui-markdown = "0.3.7"
crossterm = "0.28" crossterm = "0.28"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+6 -1
View File
@@ -11,10 +11,13 @@ Aplicação TUI de gestão de tarefas com sincronização Google Tasks.
- Reordenação de tarefas com persistência - Reordenação de tarefas com persistência
- Operações CRUD em listas e tarefas - Operações CRUD em listas e tarefas
- Seleção múltipla e ações em lote - Seleção múltipla e ações em lote
- **Página Markdown**: exploração e pré-visualização de ficheiros `.md`
- **Page Switcher**: alternar entre Tasks e Markdown
## Stack ## Stack
- **UI:** ratatui + crossterm - **UI:** ratatui 0.30 + crossterm
- **Markdown:** tui-markdown
- **Async:** tokio - **Async:** tokio
- **DB:** rusqlite (SQLite) - **DB:** rusqlite (SQLite)
- **Auth:** yup-oauth2 (OAuth 2.0) - **Auth:** yup-oauth2 (OAuth 2.0)
@@ -54,4 +57,6 @@ cargo run
| `Enter` | Completar/descompletar tarefa | | `Enter` | Completar/descompletar tarefa |
| `t` | Definir data (d=today, t=tomorrow, w=week, m=month) | | `t` | Definir data (d=today, t=tomorrow, w=week, m=month) |
| `Ctrl+r` | Sincronização forçada | | `Ctrl+r` | Sincronização forçada |
| `Ctrl+p` | Abrir Page Switcher (Tasks / Markdown) |
| `Ctrl+o` | Alterar diretório de ficheiros Markdown |
| `q` | Sair | | `q` | Sair |
+105 -6
View File
@@ -10,6 +10,8 @@ use tokio::sync::mpsc;
use crate::domain::models::*; use crate::domain::models::*;
use crate::infrastructure::api::ApiClient; use crate::infrastructure::api::ApiClient;
use crate::infrastructure::db::Db; use crate::infrastructure::db::Db;
use std::path::PathBuf;
use crate::ui::{Focus, NetworkStatus, Page, Popup}; use crate::ui::{Focus, NetworkStatus, Page, Popup};
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -42,6 +44,7 @@ pub struct App {
pub task_list_scroll: u16, pub task_list_scroll: u16,
pub detail_scroll: u16, pub detail_scroll: u16,
pub notes_scroll: u16, pub notes_scroll: u16,
pub markdown_scroll: u16,
pub calendar_scrolls: [u16; 4], pub calendar_scrolls: [u16; 4],
pub calendar_active_week: usize, pub calendar_active_week: usize,
pub db: Arc<Db>, pub db: Arc<Db>,
@@ -60,6 +63,11 @@ pub struct App {
pub popup_list_indices: Vec<(String, String)>, pub popup_list_indices: Vec<(String, String)>,
pub popup_list_selected: usize, pub popup_list_selected: usize,
pub page_switcher_selected: usize, pub page_switcher_selected: usize,
pub markdown_dir: String,
pub markdown_files: Vec<String>,
markdown_file_paths: Vec<PathBuf>,
pub markdown_selected_file: usize,
pub markdown_content: String,
auth_tx: std_mpsc::Sender<AuthEvent>, auth_tx: std_mpsc::Sender<AuthEvent>,
auth_rx: std_mpsc::Receiver<AuthEvent>, auth_rx: std_mpsc::Receiver<AuthEvent>,
sync_tx: mpsc::Sender<SyncCommand>, sync_tx: mpsc::Sender<SyncCommand>,
@@ -122,6 +130,7 @@ impl App {
task_list_scroll: 0, task_list_scroll: 0,
detail_scroll: 0, detail_scroll: 0,
notes_scroll: 0, notes_scroll: 0,
markdown_scroll: 0,
calendar_scrolls: [0; 4], calendar_scrolls: [0; 4],
calendar_active_week: 0, calendar_active_week: 0,
db, db,
@@ -139,6 +148,11 @@ impl App {
popup_list_indices: Vec::new(), popup_list_indices: Vec::new(),
popup_list_selected: 0, popup_list_selected: 0,
page_switcher_selected: 0, page_switcher_selected: 0,
markdown_dir: expand_tilde("~/dev/notes"),
markdown_files: Vec::new(),
markdown_file_paths: Vec::new(),
markdown_selected_file: 0,
markdown_content: String::new(),
auth_tx, auth_tx,
auth_rx, auth_rx,
sync_tx, sync_tx,
@@ -342,6 +356,13 @@ impl App {
return; return;
} }
if key.code == KeyCode::Char('o') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.popup_input = self.markdown_dir.clone();
self.popup_cursor = self.popup_input.len();
self.show_popup = Some(Popup::Input);
return;
}
match key.code { match key.code {
KeyCode::Tab => { KeyCode::Tab => {
match self.focus { match self.focus {
@@ -350,14 +371,21 @@ impl App {
self.calendar_active_week += 1; self.calendar_active_week += 1;
} else { } else {
self.calendar_active_week = 0; self.calendar_active_week = 0;
self.focus = Focus::TaskList; self.focus = match self.current_page {
Page::Tasks => Focus::TaskList,
Page::Markdown => Focus::FileExplorer,
};
} }
} }
_ => { _ => {
self.focus = match self.focus { self.focus = match (&self.current_page, &self.focus) {
Focus::TaskList => Focus::Detail, (Page::Tasks, Focus::TaskList) => Focus::Detail,
Focus::Detail => Focus::Calendar, (Page::Tasks, Focus::Detail) => Focus::Calendar,
Focus::Calendar => Focus::TaskList, (Page::Tasks, Focus::Calendar) => Focus::TaskList,
(Page::Tasks, _) => Focus::TaskList,
(Page::Markdown, Focus::FileExplorer) => Focus::MarkdownPreview,
(Page::Markdown, Focus::MarkdownPreview) => Focus::FileExplorer,
(Page::Markdown, _) => Focus::FileExplorer,
}; };
} }
} }
@@ -402,6 +430,15 @@ impl App {
Focus::Calendar => { Focus::Calendar => {
self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_sub(1); self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_sub(1);
} }
Focus::FileExplorer => {
if self.markdown_selected_file > 0 {
self.markdown_selected_file -= 1;
self.load_markdown_file();
}
}
Focus::MarkdownPreview => {
self.markdown_scroll = self.markdown_scroll.saturating_sub(1);
}
}, },
KeyCode::Down => match self.focus { KeyCode::Down => match self.focus {
Focus::TaskList => { Focus::TaskList => {
@@ -417,6 +454,15 @@ impl App {
Focus::Calendar => { Focus::Calendar => {
self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_add(1); self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_add(1);
} }
Focus::FileExplorer => {
if self.markdown_selected_file + 1 < self.markdown_files.len() {
self.markdown_selected_file += 1;
self.load_markdown_file();
}
}
Focus::MarkdownPreview => {
self.markdown_scroll += 1;
}
}, },
KeyCode::Right => { KeyCode::Right => {
match self.focus { match self.focus {
@@ -568,7 +614,11 @@ impl App {
} }
KeyCode::Enter => { KeyCode::Enter => {
let input = self.popup_input.trim().to_string(); let input = self.popup_input.trim().to_string();
if self.pending_bulk_move && !input.is_empty() { if self.current_page == Page::Markdown && !input.is_empty() {
self.markdown_dir = expand_tilde(&input);
self.scan_markdown_files();
self.show_popup = None;
} else if self.pending_bulk_move && !input.is_empty() {
self.pending_bulk_move = false; self.pending_bulk_move = false;
self.bulk_move_to_new_list(&input); self.bulk_move_to_new_list(&input);
self.show_popup = None; self.show_popup = None;
@@ -956,6 +1006,13 @@ impl App {
0 => Page::Tasks, 0 => Page::Tasks,
_ => Page::Markdown, _ => Page::Markdown,
}; };
self.focus = match self.current_page {
Page::Tasks => Focus::TaskList,
Page::Markdown => Focus::FileExplorer,
};
if let Page::Markdown = self.current_page {
self.scan_markdown_files();
}
self.show_popup = None; self.show_popup = None;
} }
_ => {} _ => {}
@@ -1257,6 +1314,39 @@ impl App {
} }
} }
pub fn scan_markdown_files(&mut self) {
let dir = PathBuf::from(&self.markdown_dir);
self.markdown_files.clear();
self.markdown_file_paths.clear();
self.markdown_selected_file = 0;
self.markdown_content.clear();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(false, |e| e == "md") {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
self.markdown_files.push(name.to_string());
self.markdown_file_paths.push(path);
}
}
}
}
self.markdown_files.sort();
self.markdown_file_paths.sort();
self.load_markdown_file();
}
pub fn load_markdown_file(&mut self) {
self.markdown_content.clear();
if self.markdown_selected_file < self.markdown_file_paths.len() {
let path = &self.markdown_file_paths[self.markdown_selected_file];
if let Ok(content) = std::fs::read_to_string(path) {
self.markdown_content = content;
}
}
}
fn load_lists(&mut self) { fn load_lists(&mut self) {
self.lists = self.db.get_lists(); self.lists = self.db.get_lists();
let max_scroll = self.lists.len().saturating_sub(4); let max_scroll = self.lists.len().saturating_sub(4);
@@ -1298,6 +1388,15 @@ fn sort_tasks(tasks: &mut Vec<Task>) {
}); });
} }
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return format!("{}/{}", home, rest);
}
}
path.to_string()
}
fn uuid_v4() -> String { fn uuid_v4() -> String {
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now() let now = SystemTime::now()
+4
View File
@@ -174,6 +174,7 @@ fn main() -> io::Result<()> {
task_list_scroll: app.task_list_scroll, task_list_scroll: app.task_list_scroll,
detail_scroll: app.detail_scroll, detail_scroll: app.detail_scroll,
notes_scroll: app.notes_scroll, notes_scroll: app.notes_scroll,
markdown_scroll: app.markdown_scroll,
calendar_scrolls: &app.calendar_scrolls, calendar_scrolls: &app.calendar_scrolls,
calendar_active_week: app.calendar_active_week, calendar_active_week: app.calendar_active_week,
auth_error: app.auth_error.as_deref(), auth_error: app.auth_error.as_deref(),
@@ -183,6 +184,9 @@ fn main() -> io::Result<()> {
popup_list_indices: &app.popup_list_indices, popup_list_indices: &app.popup_list_indices,
popup_list_selected: app.popup_list_selected, popup_list_selected: app.popup_list_selected,
page_switcher_selected: app.page_switcher_selected, page_switcher_selected: app.page_switcher_selected,
markdown_files: &app.markdown_files,
markdown_selected_file: app.markdown_selected_file,
markdown_content: &app.markdown_content,
}; };
draw(frame, view); draw(frame, view);
})?; })?;
+89 -7
View File
@@ -47,22 +47,104 @@ pub fn render_page_menu(frame: &mut Frame, area: Rect, current_page: &Page) {
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(&block, area); frame.render_widget(&block, area);
let active = current_page == &Page::Tasks; let items = [(Page::Tasks, " Tasks "), (Page::Markdown, " Markdown ")];
let mut spans = vec![Span::raw(" ")];
for (i, (page, label)) in items.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" | "));
}
let active = current_page == page;
let style = if active { let style = if active {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else { } else {
Style::default().fg(Color::Cyan) Style::default().fg(Color::Cyan)
}; };
spans.push(Span::styled(*label, style));
}
let text = Line::from(vec![ let paragraph = Paragraph::new(Line::from(spans));
Span::raw(" "),
Span::styled(" Tasks ", style),
]);
let paragraph = Paragraph::new(text);
frame.render_widget(paragraph, inner); frame.render_widget(paragraph, inner);
} }
pub fn render_file_explorer(
frame: &mut Frame,
area: Rect,
files: &[String],
selected: usize,
focused: bool,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
.title(" Files ")
.title_alignment(Alignment::Left);
let inner = block.inner(area);
frame.render_widget(&block, area);
let items: Vec<ListItem> = files
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == selected {
Style::default()
.fg(FOCUS_COLOR)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
ListItem::new(Line::from(Span::styled(format!(" {}", name), style)))
})
.collect();
let list = List::new(items)
.highlight_style(
Style::default()
.bg(if focused { Color::DarkGray } else { Color::Black })
.fg(SELECTED_COLOR)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
frame.render_stateful_widget(
list,
inner,
&mut ratatui::widgets::ListState::default().with_selected(Some(selected)),
);
}
pub fn render_markdown_preview(
frame: &mut Frame,
area: Rect,
content: &str,
focused: bool,
scroll: u16,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
.title(" Preview ")
.title_alignment(Alignment::Left);
if content.is_empty() {
let text = Text::from(Line::from(Span::styled(
"Select a file to preview",
Style::default().fg(Color::DarkGray),
)));
let paragraph = Paragraph::new(text).block(block).alignment(Alignment::Center);
frame.render_widget(paragraph, area);
} else {
let md_text = tui_markdown::from_str(content);
let lines: Vec<Line> = md_text.lines.into_iter().map(|l| l).collect();
let paragraph = Paragraph::new(Text::from(lines))
.block(block)
.scroll((scroll, 0))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
}
pub fn render_task_list( pub fn render_task_list(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
+41 -26
View File
@@ -2,10 +2,7 @@ pub mod components;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use ratatui::layout::{Alignment, Constraint, Direction, Layout}; use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::SyncStats; use crate::app::SyncStats;
@@ -17,6 +14,8 @@ pub enum Focus {
TaskList, TaskList,
Detail, Detail,
Calendar, Calendar,
FileExplorer,
MarkdownPreview,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -62,6 +61,7 @@ pub struct AppView<'a> {
pub task_list_scroll: u16, pub task_list_scroll: u16,
pub detail_scroll: u16, pub detail_scroll: u16,
pub notes_scroll: u16, pub notes_scroll: u16,
pub markdown_scroll: u16,
pub calendar_events: &'a [CalendarEvent], pub calendar_events: &'a [CalendarEvent],
pub calendar_scrolls: &'a [u16; 4], pub calendar_scrolls: &'a [u16; 4],
pub calendar_active_week: usize, pub calendar_active_week: usize,
@@ -72,6 +72,9 @@ pub struct AppView<'a> {
pub popup_list_indices: &'a [(String, String)], pub popup_list_indices: &'a [(String, String)],
pub popup_list_selected: usize, pub popup_list_selected: usize,
pub page_switcher_selected: usize, pub page_switcher_selected: usize,
pub markdown_files: &'a [String],
pub markdown_selected_file: usize,
pub markdown_content: &'a str,
} }
pub fn draw(frame: &mut Frame, view: AppView) { pub fn draw(frame: &mut Frame, view: AppView) {
@@ -82,24 +85,27 @@ pub fn draw(frame: &mut Frame, view: AppView) {
.constraints([ .constraints([
Constraint::Length(3), Constraint::Length(3),
Constraint::Min(0), Constraint::Min(0),
Constraint::Length(12),
Constraint::Length(1), Constraint::Length(1),
]) ])
.split(area); .split(area);
let menu_area = main_layout[0]; let menu_area = main_layout[0];
let body_area = main_layout[1]; let body_area = main_layout[1];
let calendar_area = main_layout[2]; let status_area = main_layout[2];
let status_area = main_layout[3];
render_page_menu(frame, menu_area, view.current_page); render_page_menu(frame, menu_area, view.current_page);
match view.current_page { match view.current_page {
Page::Tasks => { Page::Tasks => {
let tasks_cal = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(12)])
.split(body_area);
let body_layout = Layout::default() let body_layout = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(body_area); .split(tasks_cal[0]);
let is_task_list_focused = view.focus == Focus::TaskList; let is_task_list_focused = view.focus == Focus::TaskList;
render_task_list( render_task_list(
@@ -123,33 +129,42 @@ pub fn draw(frame: &mut Frame, view: AppView) {
is_detail_focused, is_detail_focused,
view.detail_scroll, view.detail_scroll,
); );
}
Page::Markdown => {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(" Markdown ")
.title_alignment(Alignment::Left);
let text = Text::from(Line::from(Span::styled(
"Markdown preview coming soon",
Style::default().fg(Color::DarkGray),
)));
let paragraph = Paragraph::new(text)
.block(block)
.alignment(Alignment::Center);
frame.render_widget(paragraph, body_area);
}
}
let is_calendar_focused = view.focus == Focus::Calendar; let is_calendar_focused = view.focus == Focus::Calendar;
render_calendar_panel( render_calendar_panel(
frame, frame,
calendar_area, tasks_cal[1],
view.calendar_events, view.calendar_events,
is_calendar_focused, is_calendar_focused,
view.calendar_scrolls, view.calendar_scrolls,
view.calendar_active_week, view.calendar_active_week,
); );
}
Page::Markdown => {
let md_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(body_area);
let is_explorer_focused = view.focus == Focus::FileExplorer;
render_file_explorer(
frame,
md_layout[0],
view.markdown_files,
view.markdown_selected_file,
is_explorer_focused,
);
let is_preview_focused = view.focus == Focus::MarkdownPreview;
render_markdown_preview(
frame,
md_layout[1],
view.markdown_content,
is_preview_focused,
view.markdown_scroll,
);
}
}
render_status_bar(frame, status_area, view.network_status, view.sync_stats); render_status_bar(frame, status_area, view.network_status, view.sync_stats);