first commit
This commit is contained in:
2170
Cargo.lock
generated
Normal file
2170
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "tech-news-rust"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.26"
|
||||||
|
crossterm = "0.27"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
rss = "2"
|
||||||
343
src/app.rs
Normal file
343
src/app.rs
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use ratatui::widgets::ListState;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::news::{fetch_article_content, fetch_stories, html_to_text, Story, SOURCES};
|
||||||
|
|
||||||
|
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(PartialEq, Clone, Copy)]
|
||||||
|
pub enum Tab {
|
||||||
|
TopStories,
|
||||||
|
Saved,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Async messages ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub enum AppMessage {
|
||||||
|
StoriesLoaded(Vec<Story>),
|
||||||
|
ContentLoaded(u64, String),
|
||||||
|
LoadError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App state ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
// Top Stories tab
|
||||||
|
pub stories: Vec<Story>,
|
||||||
|
pub selected: usize,
|
||||||
|
pub list_state: ListState,
|
||||||
|
|
||||||
|
// Saved tab
|
||||||
|
pub saved_stories: Vec<Story>,
|
||||||
|
pub saved_selected: usize,
|
||||||
|
pub saved_list_state: ListState,
|
||||||
|
|
||||||
|
// Source (only affects TopStories tab)
|
||||||
|
pub current_source: usize,
|
||||||
|
|
||||||
|
// Shared
|
||||||
|
pub current_tab: Tab,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub loading_stories: bool,
|
||||||
|
pub loading_content: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub content_scroll: u16,
|
||||||
|
|
||||||
|
// Feedback message shown in the status bar (e.g. "Saved!" / "Removed.")
|
||||||
|
pub status_msg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let saved_stories = load_from_disk();
|
||||||
|
let mut saved_list_state = ListState::default();
|
||||||
|
if !saved_stories.is_empty() {
|
||||||
|
saved_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
list_state.select(Some(0));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
stories: Vec::new(),
|
||||||
|
selected: 0,
|
||||||
|
list_state,
|
||||||
|
|
||||||
|
saved_stories,
|
||||||
|
saved_selected: 0,
|
||||||
|
saved_list_state,
|
||||||
|
|
||||||
|
current_source: 0,
|
||||||
|
current_tab: Tab::TopStories,
|
||||||
|
content: None,
|
||||||
|
loading_stories: false,
|
||||||
|
loading_content: false,
|
||||||
|
error: None,
|
||||||
|
content_scroll: 0,
|
||||||
|
status_msg: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Story loading ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn load_stories(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||||
|
let source_idx = self.current_source;
|
||||||
|
self.loading_stories = true;
|
||||||
|
self.error = None;
|
||||||
|
self.content = None;
|
||||||
|
self.stories.clear();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match fetch_stories(source_idx).await {
|
||||||
|
Ok(stories) => {
|
||||||
|
let _ = tx.send(AppMessage::StoriesLoaded(stories)).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(AppMessage::LoadError(e)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_source(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||||
|
if self.current_tab == Tab::TopStories {
|
||||||
|
self.current_source = (self.current_source + 1) % SOURCES.len();
|
||||||
|
self.load_stories(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_source(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||||
|
if self.current_tab == Tab::TopStories {
|
||||||
|
self.current_source = self.current_source
|
||||||
|
.checked_sub(1)
|
||||||
|
.unwrap_or(SOURCES.len() - 1);
|
||||||
|
self.load_stories(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_content_for(&mut self, story: Story, tx: mpsc::Sender<AppMessage>) {
|
||||||
|
self.content = None;
|
||||||
|
self.content_scroll = 0;
|
||||||
|
self.loading_content = false;
|
||||||
|
|
||||||
|
let id = story.id;
|
||||||
|
|
||||||
|
if let Some(text) = &story.text {
|
||||||
|
self.content = Some(html_to_text(text));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = story.url.clone() {
|
||||||
|
self.loading_content = true;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let content = fetch_article_content(&url).await;
|
||||||
|
let _ = tx.send(AppMessage::ContentLoaded(id, content)).await;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.content = Some("No article URL available for this story.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_content(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||||
|
if let Some(story) = self.stories.get(self.selected).cloned() {
|
||||||
|
self.load_content_for(story, tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_saved_content(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||||
|
if let Some(story) = self.saved_stories.get(self.saved_selected).cloned() {
|
||||||
|
self.load_content_for(story, tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message handler ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn handle_message(&mut self, msg: AppMessage, tx: mpsc::Sender<AppMessage>) {
|
||||||
|
match msg {
|
||||||
|
AppMessage::StoriesLoaded(stories) => {
|
||||||
|
self.stories = stories;
|
||||||
|
self.loading_stories = false;
|
||||||
|
self.selected = 0;
|
||||||
|
self.list_state.select(Some(0));
|
||||||
|
if self.current_tab == Tab::TopStories {
|
||||||
|
self.load_content(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::ContentLoaded(id, content) => {
|
||||||
|
// Only apply if the message matches the currently visible story
|
||||||
|
let current_id = match self.current_tab {
|
||||||
|
Tab::TopStories => self.stories.get(self.selected).map(|s| s.id),
|
||||||
|
Tab::Saved => self.saved_stories.get(self.saved_selected).map(|s| s.id),
|
||||||
|
};
|
||||||
|
if current_id == Some(id) {
|
||||||
|
self.content = Some(content);
|
||||||
|
self.loading_content = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::LoadError(e) => {
|
||||||
|
self.error = Some(e);
|
||||||
|
self.loading_stories = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn next_story(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||||
|
match self.current_tab {
|
||||||
|
Tab::TopStories => {
|
||||||
|
if !self.stories.is_empty() && self.selected < self.stories.len() - 1 {
|
||||||
|
self.selected += 1;
|
||||||
|
self.list_state.select(Some(self.selected));
|
||||||
|
self.load_content(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tab::Saved => {
|
||||||
|
if !self.saved_stories.is_empty()
|
||||||
|
&& self.saved_selected < self.saved_stories.len() - 1
|
||||||
|
{
|
||||||
|
self.saved_selected += 1;
|
||||||
|
self.saved_list_state.select(Some(self.saved_selected));
|
||||||
|
self.load_saved_content(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_story(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||||
|
match self.current_tab {
|
||||||
|
Tab::TopStories => {
|
||||||
|
if self.selected > 0 {
|
||||||
|
self.selected -= 1;
|
||||||
|
self.list_state.select(Some(self.selected));
|
||||||
|
self.load_content(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tab::Saved => {
|
||||||
|
if self.saved_selected > 0 {
|
||||||
|
self.saved_selected -= 1;
|
||||||
|
self.saved_list_state.select(Some(self.saved_selected));
|
||||||
|
self.load_saved_content(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_content_down(&mut self) {
|
||||||
|
self.content_scroll = self.content_scroll.saturating_add(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_content_up(&mut self) {
|
||||||
|
self.content_scroll = self.content_scroll.saturating_sub(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab switching ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn switch_tab(&mut self, tab: Tab, tx: mpsc::Sender<AppMessage>) {
|
||||||
|
if self.current_tab == tab {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.current_tab = tab;
|
||||||
|
self.content = None;
|
||||||
|
self.content_scroll = 0;
|
||||||
|
self.status_msg = None;
|
||||||
|
|
||||||
|
match tab {
|
||||||
|
Tab::TopStories => {
|
||||||
|
if !self.stories.is_empty() {
|
||||||
|
self.load_content(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tab::Saved => {
|
||||||
|
if self.saved_stories.is_empty() {
|
||||||
|
self.content =
|
||||||
|
Some("No saved stories yet.\n\nPress 's' on any story to save it.".to_string());
|
||||||
|
} else {
|
||||||
|
self.saved_list_state.select(Some(self.saved_selected));
|
||||||
|
self.load_saved_content(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save / unsave ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn toggle_save(&mut self, tx: mpsc::Sender<AppMessage>) {
|
||||||
|
// Identify which story is currently focused
|
||||||
|
let id = match self.current_tab {
|
||||||
|
Tab::TopStories => self.stories.get(self.selected).map(|s| s.id),
|
||||||
|
Tab::Saved => self.saved_stories.get(self.saved_selected).map(|s| s.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(id) = id else { return };
|
||||||
|
|
||||||
|
if let Some(pos) = self.saved_stories.iter().position(|s| s.id == id) {
|
||||||
|
// Already saved → remove
|
||||||
|
self.saved_stories.remove(pos);
|
||||||
|
|
||||||
|
if self.current_tab == Tab::Saved {
|
||||||
|
if self.saved_stories.is_empty() {
|
||||||
|
self.saved_selected = 0;
|
||||||
|
self.saved_list_state.select(None);
|
||||||
|
self.content = Some(
|
||||||
|
"No saved stories yet.\n\nPress 's' on any story to save it.".to_string(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.saved_selected =
|
||||||
|
self.saved_selected.min(self.saved_stories.len() - 1);
|
||||||
|
self.saved_list_state.select(Some(self.saved_selected));
|
||||||
|
self.load_saved_content(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.status_msg = Some("Removed from saved.".to_string());
|
||||||
|
} else {
|
||||||
|
// Not saved → save
|
||||||
|
let story = match self.current_tab {
|
||||||
|
Tab::TopStories => self.stories.get(self.selected).cloned(),
|
||||||
|
Tab::Saved => None,
|
||||||
|
};
|
||||||
|
if let Some(story) = story {
|
||||||
|
self.saved_stories.push(story);
|
||||||
|
self.status_msg = Some("Saved!".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save_to_disk();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_saved(&self, id: u64) -> bool {
|
||||||
|
self.saved_stories.iter().any(|s| s.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistence ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn save_to_disk(&self) {
|
||||||
|
let path = save_file_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(&self.saved_stories) {
|
||||||
|
std::fs::write(path, json).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_file_path() -> PathBuf {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||||
|
PathBuf::from(home)
|
||||||
|
.join(".local")
|
||||||
|
.join("share")
|
||||||
|
.join("tech-news")
|
||||||
|
.join("saved.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_from_disk() -> Vec<Story> {
|
||||||
|
let path = save_file_path();
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
79
src/main.rs
Normal file
79
src/main.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
mod app;
|
||||||
|
mod news;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use app::{App, AppMessage, Tab};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let result = run_app(&mut terminal).await;
|
||||||
|
|
||||||
|
// Always restore terminal even on error
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_app(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (tx, mut rx) = mpsc::channel::<AppMessage>(100);
|
||||||
|
let mut app = App::new();
|
||||||
|
|
||||||
|
// Kick off the initial story fetch
|
||||||
|
app.load_stories(tx.clone());
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Drain all pending async messages before drawing
|
||||||
|
while let Ok(msg) = rx.try_recv() {
|
||||||
|
app.handle_message(msg, tx.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||||
|
|
||||||
|
// Poll for keyboard input with a short timeout so messages keep flowing
|
||||||
|
if event::poll(std::time::Duration::from_millis(200))? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => app.next_story(tx.clone()),
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => app.prev_story(tx.clone()),
|
||||||
|
KeyCode::PageDown => app.scroll_content_down(),
|
||||||
|
KeyCode::PageUp => app.scroll_content_up(),
|
||||||
|
KeyCode::Char('r') => app.load_stories(tx.clone()),
|
||||||
|
KeyCode::Char('s') => app.toggle_save(tx.clone()),
|
||||||
|
KeyCode::Char('1') => app.switch_tab(Tab::TopStories, tx.clone()),
|
||||||
|
KeyCode::Char('2') => app.switch_tab(Tab::Saved, tx.clone()),
|
||||||
|
KeyCode::Right => app.next_source(tx.clone()),
|
||||||
|
KeyCode::Left => app.prev_source(tx.clone()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
355
src/news.rs
Normal file
355
src/news.rs
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ── Story ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Story {
|
||||||
|
pub id: u64,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub score: Option<i32>,
|
||||||
|
pub by: Option<String>,
|
||||||
|
pub descendants: Option<i32>,
|
||||||
|
pub time: Option<u64>,
|
||||||
|
pub text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source definitions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct SourceDef {
|
||||||
|
pub label: &'static str,
|
||||||
|
pub kind: SourceKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum SourceKind {
|
||||||
|
HackerNews,
|
||||||
|
Reddit(&'static str), // subreddit name
|
||||||
|
Rss(&'static str), // feed URL
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static SOURCES: &[SourceDef] = &[
|
||||||
|
SourceDef { label: "HN Tech", kind: SourceKind::HackerNews },
|
||||||
|
SourceDef { label: "Games", kind: SourceKind::Reddit("gaming") },
|
||||||
|
SourceDef { label: "Science", kind: SourceKind::Reddit("science") },
|
||||||
|
SourceDef { label: "Programming", kind: SourceKind::Reddit("programming") },
|
||||||
|
SourceDef { label: "BBC News", kind: SourceKind::Rss("https://feeds.bbci.co.uk/news/rss.xml") },
|
||||||
|
SourceDef { label: "NASA", kind: SourceKind::Rss("https://www.nasa.gov/news-release/feed/") },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Dispatcher ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn fetch_stories(source_idx: usize) -> Result<Vec<Story>, String> {
|
||||||
|
match &SOURCES[source_idx].kind {
|
||||||
|
SourceKind::HackerNews => fetch_hn_stories(10).await.map_err(|e| e.to_string()),
|
||||||
|
SourceKind::Reddit(sub) => fetch_reddit_stories(sub).await,
|
||||||
|
SourceKind::Rss(url) => fetch_rss_stories(url).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hacker News ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn fetch_hn_stories(count: usize) -> Result<Vec<Story>, reqwest::Error> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let ids: Vec<u64> = client
|
||||||
|
.get("https://hacker-news.firebaseio.com/v0/topstories.json")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let top_ids: Vec<u64> = ids.into_iter().take(count).collect();
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for id in top_ids {
|
||||||
|
let client = client.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
let url = format!("https://hacker-news.firebaseio.com/v0/item/{}.json", id);
|
||||||
|
match client.get(&url).send().await {
|
||||||
|
Ok(r) => r.json::<Story>().await.ok(),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stories = Vec::new();
|
||||||
|
for h in handles {
|
||||||
|
if let Ok(Some(s)) = h.await {
|
||||||
|
stories.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(stories)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reddit JSON API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RedditListing {
|
||||||
|
data: RedditListingData,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RedditListingData {
|
||||||
|
children: Vec<RedditChild>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RedditChild {
|
||||||
|
data: RedditPostData,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RedditPostData {
|
||||||
|
title: String,
|
||||||
|
url: Option<String>,
|
||||||
|
permalink: String,
|
||||||
|
author: String,
|
||||||
|
score: i32,
|
||||||
|
num_comments: i32,
|
||||||
|
created_utc: f64,
|
||||||
|
selftext: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_reddit_stories(subreddit: &str) -> Result<Vec<Story>, String> {
|
||||||
|
let url = format!(
|
||||||
|
"https://www.reddit.com/r/{}/top.json?limit=10&t=day",
|
||||||
|
subreddit
|
||||||
|
);
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
// Reddit requires a descriptive user-agent
|
||||||
|
.user_agent("tech-news-reader/1.0")
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let listing: RedditListing = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let stories = listing
|
||||||
|
.data
|
||||||
|
.children
|
||||||
|
.into_iter()
|
||||||
|
.map(|child| {
|
||||||
|
let d = child.data;
|
||||||
|
// Prefer the external link; fall back to the Reddit thread
|
||||||
|
let story_url = match &d.url {
|
||||||
|
Some(u) if !u.contains("reddit.com") => Some(u.clone()),
|
||||||
|
_ => Some(format!("https://www.reddit.com{}", d.permalink)),
|
||||||
|
};
|
||||||
|
Story {
|
||||||
|
id: str_hash(&d.permalink),
|
||||||
|
title: Some(d.title),
|
||||||
|
url: story_url,
|
||||||
|
score: Some(d.score),
|
||||||
|
by: Some(d.author),
|
||||||
|
descendants: Some(d.num_comments),
|
||||||
|
time: Some(d.created_utc as u64),
|
||||||
|
text: d.selftext.filter(|s| !s.is_empty()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(stories)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RSS feeds (BBC, NASA, …) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn fetch_rss_stories(feed_url: &str) -> Result<Vec<Story>, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0")
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let bytes = client
|
||||||
|
.get(feed_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let channel = rss::Channel::read_from(std::io::Cursor::new(bytes))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let stories = channel
|
||||||
|
.items()
|
||||||
|
.iter()
|
||||||
|
.take(10)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, item)| {
|
||||||
|
let url = item.link().map(|s| s.to_string());
|
||||||
|
Story {
|
||||||
|
id: url.as_deref().map(str_hash).unwrap_or(i as u64),
|
||||||
|
title: item.title().map(|s| s.to_string()),
|
||||||
|
url,
|
||||||
|
score: None,
|
||||||
|
by: item
|
||||||
|
.author()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
item.dublin_core_ext()
|
||||||
|
.and_then(|dc| dc.creators().first())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}),
|
||||||
|
descendants: None,
|
||||||
|
time: item.pub_date().and_then(parse_rfc2822),
|
||||||
|
text: item.description().map(|s| s.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(stories)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Article content ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn fetch_article_content(url: &str) -> String {
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.user_agent("Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0")
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return format!("Failed to create HTTP client: {}", e),
|
||||||
|
};
|
||||||
|
|
||||||
|
match client.get(url).send().await {
|
||||||
|
Ok(response) => match response.text().await {
|
||||||
|
Ok(html) => html_to_text(&html),
|
||||||
|
Err(e) => format!("Failed to read response body:\n{}", e),
|
||||||
|
},
|
||||||
|
Err(e) => format!(
|
||||||
|
"Could not fetch article content.\n\nError: {}\n\nOpen in your browser:\n{}",
|
||||||
|
e, url
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML → plain text ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn html_to_text(html: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut in_tag = false;
|
||||||
|
let mut in_skip = false;
|
||||||
|
let mut tag_buf = String::new();
|
||||||
|
|
||||||
|
for ch in html.chars() {
|
||||||
|
if ch == '<' {
|
||||||
|
in_tag = true;
|
||||||
|
tag_buf.clear();
|
||||||
|
} else if ch == '>' && in_tag {
|
||||||
|
in_tag = false;
|
||||||
|
let tag = tag_buf.trim().to_lowercase();
|
||||||
|
let first_word = tag.split_whitespace().next().unwrap_or("");
|
||||||
|
let closing = first_word.starts_with('/');
|
||||||
|
let name = first_word.trim_start_matches('/');
|
||||||
|
|
||||||
|
if name == "script" || name == "style" {
|
||||||
|
in_skip = !closing;
|
||||||
|
} else if !in_skip {
|
||||||
|
match name {
|
||||||
|
"br" => result.push('\n'),
|
||||||
|
"p" | "/p" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "li"
|
||||||
|
| "tr" | "blockquote" => {
|
||||||
|
if !result.ends_with('\n') {
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if in_tag {
|
||||||
|
tag_buf.push(ch);
|
||||||
|
} else if !in_skip {
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = result
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace(" ", " ")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("/", "/");
|
||||||
|
|
||||||
|
let mut cleaned = String::new();
|
||||||
|
let mut blank_count = 0u32;
|
||||||
|
for line in decoded.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
blank_count += 1;
|
||||||
|
if blank_count == 1 {
|
||||||
|
cleaned.push('\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
blank_count = 0;
|
||||||
|
cleaned.push_str(trimmed);
|
||||||
|
cleaned.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn str_hash(s: &str) -> u64 {
|
||||||
|
let mut h = DefaultHasher::new();
|
||||||
|
s.hash(&mut h);
|
||||||
|
h.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an RFC 2822 date string (e.g. "Sat, 11 Apr 2026 12:00:00 +0000")
|
||||||
|
/// into a Unix timestamp. Returns None if the format is unrecognised.
|
||||||
|
fn parse_rfc2822(s: &str) -> Option<u64> {
|
||||||
|
// Drop optional "DDD, " prefix
|
||||||
|
let s = match s.find(',') {
|
||||||
|
Some(pos) => s[pos + 1..].trim(),
|
||||||
|
None => s.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let parts: Vec<&str> = s.split_whitespace().collect();
|
||||||
|
if parts.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let day: i64 = parts[0].parse().ok()?;
|
||||||
|
let month: i64 = match parts[1] {
|
||||||
|
"Jan" => 1, "Feb" => 2, "Mar" => 3, "Apr" => 4,
|
||||||
|
"May" => 5, "Jun" => 6, "Jul" => 7, "Aug" => 8,
|
||||||
|
"Sep" => 9, "Oct" => 10, "Nov" => 11, "Dec" => 12,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let year: i64 = parts[2].parse().ok()?;
|
||||||
|
|
||||||
|
let hms: Vec<i64> = parts[3].split(':').filter_map(|p| p.parse().ok()).collect();
|
||||||
|
let (h, m, sec) = match hms.as_slice() {
|
||||||
|
[h, m, s, ..] => (*h, *m, *s),
|
||||||
|
[h, m] => (*h, *m, 0),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Julian Day Number → unix timestamp
|
||||||
|
let ym = (month - 14) / 12;
|
||||||
|
let yyyy = year + 4800 + ym;
|
||||||
|
let mm = month - 12 * ym - 3;
|
||||||
|
let jdn = day + (153 * mm + 2) / 5 + 365 * yyyy + yyyy / 4 - yyyy / 100 + yyyy / 400 - 32045;
|
||||||
|
let days = jdn - 2_440_588; // JDN of 1970-01-01
|
||||||
|
if days < 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(days as u64 * 86_400 + h as u64 * 3_600 + m as u64 * 60 + sec as u64)
|
||||||
|
}
|
||||||
438
src/ui.rs
Normal file
438
src/ui.rs
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::{App, Tab};
|
||||||
|
use crate::news::SOURCES;
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||||
|
let size = f.size();
|
||||||
|
|
||||||
|
// Layout: tab bar (1) | source bar (1) | main panels (flex) | status bar (1)
|
||||||
|
let outer = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(size);
|
||||||
|
|
||||||
|
let tab_area = outer[0];
|
||||||
|
let source_area = outer[1];
|
||||||
|
let main_area = outer[2];
|
||||||
|
let status_area = outer[3];
|
||||||
|
|
||||||
|
// Main area: left 30% | right 70%
|
||||||
|
let panes = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||||
|
.split(main_area);
|
||||||
|
|
||||||
|
draw_tab_bar(f, app, tab_area);
|
||||||
|
draw_source_bar(f, app, source_area);
|
||||||
|
draw_stories_panel(f, app, panes[0]);
|
||||||
|
draw_content_panel(f, app, panes[1]);
|
||||||
|
draw_status_bar(f, app, status_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab bar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn draw_tab_bar(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let active = Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let inactive = Style::default().fg(Color::DarkGray).bg(Color::Black);
|
||||||
|
|
||||||
|
let saved_label = if app.saved_stories.is_empty() {
|
||||||
|
" [2] Saved ".to_string()
|
||||||
|
} else {
|
||||||
|
format!(" [2] Saved ({}) ", app.saved_stories.len())
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" [1] Top Stories ",
|
||||||
|
if app.current_tab == Tab::TopStories { active } else { inactive },
|
||||||
|
),
|
||||||
|
Span::styled(" ", Style::default().bg(Color::Black)),
|
||||||
|
Span::styled(
|
||||||
|
saved_label,
|
||||||
|
if app.current_tab == Tab::Saved { active } else { inactive },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(line).style(Style::default().bg(Color::Black)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source bar ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn draw_source_bar(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let active = app.current_tab == Tab::TopStories;
|
||||||
|
|
||||||
|
let mut spans = vec![Span::styled(" ← ", Style::default().fg(Color::DarkGray))];
|
||||||
|
|
||||||
|
for (i, src) in SOURCES.iter().enumerate() {
|
||||||
|
let is_selected = i == app.current_source;
|
||||||
|
let style = if is_selected && active {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_selected {
|
||||||
|
// on Saved tab: show current source but dimmed
|
||||||
|
Style::default().fg(Color::DarkGray).bg(Color::Black).add_modifier(Modifier::BOLD)
|
||||||
|
} else if active {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Black) // almost invisible on Saved tab
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(format!(" {} ", src.label), style));
|
||||||
|
if i < SOURCES.len() - 1 {
|
||||||
|
spans.push(Span::styled(" ", Style::default().bg(Color::Black)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spans.push(Span::styled(" →", Style::default().fg(Color::DarkGray)));
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Black)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Left panel ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn draw_stories_panel(f: &mut Frame, app: &mut App, area: Rect) {
|
||||||
|
let panel_title = match app.current_tab {
|
||||||
|
Tab::TopStories => " HN Top Stories ",
|
||||||
|
Tab::Saved => " Saved Stories ",
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(Line::from(vec![Span::styled(
|
||||||
|
panel_title,
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)]))
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
match app.current_tab {
|
||||||
|
Tab::TopStories => draw_top_stories_list(f, app, area, block),
|
||||||
|
Tab::Saved => draw_saved_list(f, app, area, block),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_top_stories_list(
|
||||||
|
f: &mut Frame,
|
||||||
|
app: &mut App,
|
||||||
|
area: Rect,
|
||||||
|
block: Block,
|
||||||
|
) {
|
||||||
|
if app.loading_stories {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(" Loading stories…")
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(err) = app.error.clone() {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(format!("Error:\n\n{}", err))
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().fg(Color::Red))
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.stories.is_empty() {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(" No stories.\n Press 'r' to refresh.")
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.stories
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, story)| {
|
||||||
|
let title = story.title.as_deref().unwrap_or("Untitled");
|
||||||
|
let score = story.score.unwrap_or(0);
|
||||||
|
let time_str = story.time.map(format_time_ago).unwrap_or_default();
|
||||||
|
let bookmark = if app.is_saved(story.id) {
|
||||||
|
Span::styled("★ ", Style::default().fg(Color::Yellow))
|
||||||
|
} else {
|
||||||
|
Span::raw(" ")
|
||||||
|
};
|
||||||
|
|
||||||
|
let title_line = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{:2}. ", i + 1),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
bookmark,
|
||||||
|
Span::raw(title.to_string()),
|
||||||
|
]);
|
||||||
|
let meta_line = Line::from(vec![Span::styled(
|
||||||
|
format!(" ★{} {}", score, time_str),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
ListItem::new(Text::from(vec![title_line, meta_line]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(block)
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Blue)
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.highlight_symbol("▶ ");
|
||||||
|
|
||||||
|
f.render_stateful_widget(list, area, &mut app.list_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_saved_list(f: &mut Frame, app: &mut App, area: Rect, block: Block) {
|
||||||
|
if app.saved_stories.is_empty() {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(" No saved stories.\n\n Press 's' on any\n story to save it.")
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.saved_stories
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, story)| {
|
||||||
|
let title = story.title.as_deref().unwrap_or("Untitled");
|
||||||
|
let score = story.score.unwrap_or(0);
|
||||||
|
let time_str = story.time.map(format_time_ago).unwrap_or_default();
|
||||||
|
|
||||||
|
let title_line = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{:2}. ", i + 1),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
Span::styled("★ ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw(title.to_string()),
|
||||||
|
]);
|
||||||
|
let meta_line = Line::from(vec![Span::styled(
|
||||||
|
format!(" ★{} {}", score, time_str),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
ListItem::new(Text::from(vec![title_line, meta_line]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(block)
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Blue)
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.highlight_symbol("▶ ");
|
||||||
|
|
||||||
|
f.render_stateful_widget(list, area, &mut app.saved_list_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Right panel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn draw_content_panel(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let active_stories = match app.current_tab {
|
||||||
|
Tab::TopStories => &app.stories,
|
||||||
|
Tab::Saved => &app.saved_stories,
|
||||||
|
};
|
||||||
|
let active_idx = match app.current_tab {
|
||||||
|
Tab::TopStories => app.selected,
|
||||||
|
Tab::Saved => app.saved_selected,
|
||||||
|
};
|
||||||
|
|
||||||
|
let selected = active_stories.get(active_idx);
|
||||||
|
|
||||||
|
let panel_title = selected
|
||||||
|
.and_then(|s| s.title.as_deref())
|
||||||
|
.unwrap_or("Tech News Reader");
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(format!(" {} ", panel_title))
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::DarkGray));
|
||||||
|
|
||||||
|
if active_stories.is_empty() {
|
||||||
|
let msg = match app.current_tab {
|
||||||
|
Tab::TopStories => "\n Fetching top stories from Hacker News…",
|
||||||
|
Tab::Saved => "\n No saved stories yet.\n\n Navigate to Top Stories and\n press 's' to save a story.",
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(msg)
|
||||||
|
.block(block)
|
||||||
|
.style(Style::default().fg(Color::DarkGray))
|
||||||
|
.wrap(Wrap { trim: true }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(story) = selected {
|
||||||
|
let score = story.score.unwrap_or(0);
|
||||||
|
let author = story.by.as_deref().unwrap_or("unknown");
|
||||||
|
let comments = story.descendants.unwrap_or(0);
|
||||||
|
let time_str = story.time.map(format_time_ago).unwrap_or_default();
|
||||||
|
let saved_tag = if app.is_saved(story.id) {
|
||||||
|
Span::styled(" ★ saved", Style::default().fg(Color::Yellow))
|
||||||
|
} else {
|
||||||
|
Span::raw("")
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" ★ ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(format!("{} pts", score), Style::default().fg(Color::Yellow)),
|
||||||
|
Span::raw(" · "),
|
||||||
|
Span::styled(format!("by {}", author), Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" · "),
|
||||||
|
Span::raw(time_str),
|
||||||
|
Span::raw(" · "),
|
||||||
|
Span::styled(
|
||||||
|
format!("{} comments", comments),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
saved_tag,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if let Some(url) = &story.url {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" URL: ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(url.as_str(), Style::default().fg(Color::Blue)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" ".to_string() + &"─".repeat(area.width.saturating_sub(4) as usize),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
if app.loading_content {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" Loading article content…",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
} else if let Some(content) = &app.content {
|
||||||
|
for line in content.lines() {
|
||||||
|
lines.push(Line::from(format!(" {}", line)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(Text::from(lines))
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((app.content_scroll, 0));
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status bar ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let right_side = if let Some(msg) = &app.status_msg {
|
||||||
|
Span::styled(
|
||||||
|
format!(" {}", msg),
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::DarkGray)
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Span::raw("")
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = Line::from(vec![
|
||||||
|
key(" ↑/k"),
|
||||||
|
sep(" up "),
|
||||||
|
key("↓/j"),
|
||||||
|
sep(" down "),
|
||||||
|
key("←/→"),
|
||||||
|
sep(" source "),
|
||||||
|
key("PgUp/PgDn"),
|
||||||
|
sep(" scroll "),
|
||||||
|
key("s"),
|
||||||
|
sep(" save "),
|
||||||
|
key("1"),
|
||||||
|
sep("/"),
|
||||||
|
key("2"),
|
||||||
|
sep(" tabs "),
|
||||||
|
key("r"),
|
||||||
|
sep(" refresh "),
|
||||||
|
key("q"),
|
||||||
|
sep(" quit"),
|
||||||
|
right_side,
|
||||||
|
]);
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(line).style(Style::default().bg(Color::DarkGray)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(label: &'static str) -> Span<'static> {
|
||||||
|
Span::styled(label, Style::default().fg(Color::Black).bg(Color::Cyan))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sep(label: &'static str) -> Span<'static> {
|
||||||
|
Span::styled(
|
||||||
|
label,
|
||||||
|
Style::default().bg(Color::DarkGray).fg(Color::White),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn format_time_ago(unix_time: u64) -> String {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let diff = now.saturating_sub(unix_time);
|
||||||
|
if diff < 60 {
|
||||||
|
"just now".to_string()
|
||||||
|
} else if diff < 3600 {
|
||||||
|
format!("{}m ago", diff / 60)
|
||||||
|
} else if diff < 86400 {
|
||||||
|
format!("{}h ago", diff / 3600)
|
||||||
|
} else {
|
||||||
|
format!("{}d ago", diff / 86400)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
target/.rustc_info.json
Normal file
1
target/.rustc_info.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"rustc_fingerprint":12575832732247077041,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.94.1 (e408947bf 2026-03-25)\nbinary: rustc\ncommit-hash: e408947bfd200af42db322daf0fadfe7e26d3bd1\ncommit-date: 2026-03-25\nhost: x86_64-unknown-linux-gnu\nrelease: 1.94.1\nLLVM version: 21.1.8\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/arthur/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||||
3
target/CACHEDIR.TAG
Normal file
3
target/CACHEDIR.TAG
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Signature: 8a477f597d28d172789f06886806bc55
|
||||||
|
# This file is a cache directory tag created by cargo.
|
||||||
|
# For information about cache directory tags see https://bford.info/cachedir/
|
||||||
0
target/debug/.cargo-lock
Normal file
0
target/debug/.cargo-lock
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
cb5984a63912944e
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"alloc\"]","declared_features":"[\"alloc\", \"default\", \"fresh-rust\", \"nightly\", \"serde\", \"std\"]","target":5388200169723499962,"profile":12994027242049262075,"path":619616282504145460,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/allocator-api2-c8f86b29da3b8e3b/dep-lib-allocator_api2","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a92755eb0cc170f7
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"builders\", \"default\", \"derive_builder\", \"never\"]","declared_features":"[\"builders\", \"default\", \"derive_builder\", \"never\", \"serde\", \"with-serde\"]","target":13033617321090678130,"profile":15657897354478470176,"path":908793774483672517,"deps":[[1462335029370885857,"quick_xml",false,1722954145813896942],[3856126590694406759,"chrono",false,7232410654852562550],[6219554740863759696,"derive_builder",false,7578984412507588111],[12978237078517444867,"never",false,15288010464058099274],[17752799662437466077,"diligent_date_parser",false,16700558642039594699]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atom_syndication-ade6759b54edb6da/dep-lib-atom_syndication","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
885ad53e32e2f9fe
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":15657897354478470176,"path":8988350338904927053,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/atomic-waker-843b52043047247e/dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
7fea944c97db42e7
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[]","declared_features":"[]","target":6962977057026645649,"profile":2225463790103693989,"path":6839797016381145001,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/autocfg-9ef4b25a9bd36cd2/dep-lib-autocfg","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/base64-e5cc050039038c59/dep-lib-base64
Normal file
BIN
target/debug/.fingerprint/base64-e5cc050039038c59/dep-lib-base64
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
b9648a5c8b369634
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":15657897354478470176,"path":16618588621241649313,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/base64-e5cc050039038c59/dep-lib-base64","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a68ad499da59f93b
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"arbitrary\", \"bytemuck\", \"example_generated\", \"serde\", \"serde_core\", \"std\"]","target":7691312148208718491,"profile":15657897354478470176,"path":6020575681240194064,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/bitflags-b60e409526351a4f/dep-lib-bitflags","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/bytes-394efa973d3a621d/dep-lib-bytes
Normal file
BIN
target/debug/.fingerprint/bytes-394efa973d3a621d/dep-lib-bytes
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
32ddc84b25e855aa
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"extra-platforms\", \"serde\", \"std\"]","target":11402411492164584411,"profile":5585765287293540646,"path":6468508731397481850,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/bytes-394efa973d3a621d/dep-lib-bytes","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
2cfb69c12a6b273b
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[]","declared_features":"[]","target":10353004457644949388,"profile":15657897354478470176,"path":15323988964894262287,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cassowary-21a48475a531bb09/dep-lib-cassowary","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
8ad186d6d0e2cc34
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13710694652376480987,"profile":15657897354478470176,"path":16945805339708472456,"deps":[[14156967978702956262,"rustversion",false,2681997257224848000]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/castaway-0888e1655b1a9c88/dep-lib-castaway","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/cc-2a2381873bc497da/dep-lib-cc
Normal file
BIN
target/debug/.fingerprint/cc-2a2381873bc497da/dep-lib-cc
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
1
target/debug/.fingerprint/cc-2a2381873bc497da/lib-cc
Normal file
1
target/debug/.fingerprint/cc-2a2381873bc497da/lib-cc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
5997136faccfba20
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"jobserver\", \"parallel\"]","target":11042037588551934598,"profile":4333757155065362140,"path":14161913320590019938,"deps":[[8410525223747752176,"shlex",false,8656756483659441355],[9159843920629750842,"find_msvc_tools",false,13402027261808900383]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cc-2a2381873bc497da/dep-lib-cc","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/cfg-if-240b169be3bd5d4e/dep-lib-cfg_if
Normal file
BIN
target/debug/.fingerprint/cfg-if-240b169be3bd5d4e/dep-lib-cfg_if
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
19e929c603a03493
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"core\", \"rustc-dep-of-std\"]","target":13840298032947503755,"profile":15657897354478470176,"path":13005896658499936504,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cfg-if-240b169be3bd5d4e/dep-lib-cfg_if","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/chrono-14e6bc6a6850799f/dep-lib-chrono
Normal file
BIN
target/debug/.fingerprint/chrono-14e6bc6a6850799f/dep-lib-chrono
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
76be3cf02baf5e64
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"alloc\"]","declared_features":"[\"__internal_bench\", \"alloc\", \"arbitrary\", \"clock\", \"core-error\", \"default\", \"defmt\", \"iana-time-zone\", \"js-sys\", \"libc\", \"now\", \"oldtime\", \"pure-rust-locales\", \"rkyv\", \"rkyv-16\", \"rkyv-32\", \"rkyv-64\", \"rkyv-validation\", \"serde\", \"std\", \"unstable-locales\", \"wasm-bindgen\", \"wasmbind\", \"winapi\", \"windows-link\"]","target":15315924755136109342,"profile":15657897354478470176,"path":1602253831651938195,"deps":[[5157631553186200874,"num_traits",false,12991895983547534235]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/chrono-14e6bc6a6850799f/dep-lib-chrono","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
9b886f8893e5d6ae
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"arbitrary\", \"bytes\", \"markup\", \"proptest\", \"quickcheck\", \"rkyv\", \"serde\", \"smallvec\"]","target":12681387934967326413,"profile":15657897354478470176,"path":10759431940353196692,"deps":[[1127187624154154345,"castaway",false,3804665171801461130],[5532778797167691009,"itoa",false,8818657059401401231],[6400797066282925533,"ryu",false,16350468532189042288],[7667230146095136825,"cfg_if",false,10607278960434342169],[13785866025199020095,"static_assertions",false,14862852550781130700]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/compact_str-efab33ad941c7db8/dep-lib-compact_str","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a28d4cf3718f00b6
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"bracketed-paste\", \"default\", \"events\", \"windows\"]","declared_features":"[\"bracketed-paste\", \"default\", \"event-stream\", \"events\", \"filedescriptor\", \"serde\", \"use-dev-tty\", \"windows\"]","target":7162149947039624270,"profile":15657897354478470176,"path":15856675640070458482,"deps":[[4627466251042474366,"signal_hook_mio",false,3915479502761970361],[10703860158168350592,"mio",false,12055936156763945462],[12111499963430175700,"libc",false,2799162022118210411],[12459942763388630573,"parking_lot",false,10945704542167786807],[16909888598953886583,"bitflags",false,4321584112857287334],[17154765528929363175,"signal_hook",false,10674799042310853444]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/crossterm-0ecb4b119fa3a89c/dep-lib-crossterm","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3f538784ad9af30f
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"default\", \"suggestions\"]","declared_features":"[\"default\", \"diagnostics\", \"suggestions\"]","target":10425393644641512883,"profile":4791074740661137825,"path":9429561734597034915,"deps":[[391311489375721310,"darling_macro",false,2560528864453042054],[7492649247881633246,"darling_core",false,13530443843825157893]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/darling-fac78cc8855a079a/dep-lib-darling","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
05c79114fdcac5bb
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"strsim\", \"suggestions\"]","declared_features":"[\"diagnostics\", \"strsim\", \"suggestions\"]","target":13428977600034985537,"profile":2225463790103693989,"path":10695231833177288263,"deps":[[1345404220202658316,"fnv",false,5820204425362057141],[4289358735036141001,"proc_macro2",false,16644934188774033358],[10420560437213941093,"syn",false,10887743778332594988],[11166530783118767604,"strsim",false,8015746165858899359],[13111758008314797071,"quote",false,10965562302966806733],[15383437925411509181,"ident_case",false,6797437418825428054]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/darling_core-97f0605d3fffe38b/dep-lib-darling_core","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
869fdb9a6fd38823
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[]","declared_features":"[]","target":15692157989113707310,"profile":2225463790103693989,"path":11994422053592691527,"deps":[[7492649247881633246,"darling_core",false,13530443843825157893],[10420560437213941093,"syn",false,10887743778332594988],[13111758008314797071,"quote",false,10965562302966806733]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/darling_macro-9fdaee6df028cfa9/dep-lib-darling_macro","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0fc6bc012bf62d69
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"default\", \"std\"]","declared_features":"[\"alloc\", \"clippy\", \"default\", \"std\"]","target":8513585915772363107,"profile":9398156148949759868,"path":18303750549334772989,"deps":[[16001110498200919332,"derive_builder_macro",false,1547462938064423890]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/derive_builder-a8db94dcfaf1df2d/dep-lib-derive_builder","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
51980137f55a24cc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"lib_has_std\"]","declared_features":"[\"alloc\", \"clippy\", \"lib_has_std\"]","target":15805722739128704647,"profile":2225463790103693989,"path":7483024297130915841,"deps":[[496455418292392305,"darling",false,1149432399953089343],[4289358735036141001,"proc_macro2",false,16644934188774033358],[10420560437213941093,"syn",false,10887743778332594988],[13111758008314797071,"quote",false,10965562302966806733]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/derive_builder_core-8cfa3811d44c4fc6/dep-lib-derive_builder_core","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
d28b69e657b17915
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[\"lib_has_std\"]","declared_features":"[\"alloc\", \"clippy\", \"lib_has_std\"]","target":15229808779680689443,"profile":2225463790103693989,"path":16050740893568096425,"deps":[[4003231138667150418,"derive_builder_core",false,14709982292135221329],[10420560437213941093,"syn",false,10887743778332594988]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/derive_builder_macro-06d851d5c70afe9f/dep-lib-derive_builder_macro","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
cb9e59019a4dc4e7
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[]","declared_features":"[]","target":4843579053871636606,"profile":15657897354478470176,"path":75932892441500446,"deps":[[3856126590694406759,"chrono",false,7232410654852562550]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/diligent-date-parser-2b4a70e922d1d666/dep-lib-diligent_date_parser","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
95e7de704ca4e740
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":5391851738765093524,"features":"[]","declared_features":"[\"default\", \"std\"]","target":9331843185013996172,"profile":2225463790103693989,"path":6518293226941545540,"deps":[[4289358735036141001,"proc_macro2",false,16644934188774033358],[10420560437213941093,"syn",false,10887743778332594988],[13111758008314797071,"quote",false,10965562302966806733]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/displaydoc-e177775816be846e/dep-lib-displaydoc","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
target/debug/.fingerprint/either-ed099fd5024e423c/dep-lib-either
Normal file
BIN
target/debug/.fingerprint/either-ed099fd5024e423c/dep-lib-either
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
20d6a142db72b9d7
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user