diff --git a/src/config.rs b/src/config.rs index c2039bd..7c8c76b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,9 +11,23 @@ use toml_edit::{Array, DocumentMut, Item, Table, value}; #[derive(Default, Debug)] pub struct Config { + pub channels: BTreeMap, pub categories: BTreeMap>, } +impl Config { + pub fn subscriptions(&self) -> Vec { + let mut channels: Vec<_> = self + .channels + .values() + .chain(self.categories.values().flatten()) + .collect(); + channels.sort_unstable(); + channels.dedup(); + channels.into_iter().cloned().collect() + } +} + pub fn config_path() -> io::Result { resolve_path( std::env::var_os("XDG_CONFIG_HOME"), @@ -35,7 +49,12 @@ fn resolve_path(xdg: Option, home: Option) -> io::Result io::Result { let (_, document) = read_document(path)?; - parse_categories(&document, &parse_channels(&document)?) + let channels = parse_channels(&document)?; + let categories = parse_categories(&document, &channels)?; + Ok(Config { + channels, + categories, + }) } pub fn set_channel_tags( @@ -163,9 +182,12 @@ pub fn set_channel_tags( } } } - let config = parse_categories(&document, &mappings)?; + let categories = parse_categories(&document, &mappings)?; save(path, original.as_deref(), document.to_string().as_bytes())?; - Ok(config) + Ok(Config { + channels: mappings, + categories, + }) } fn invalid(message: impl Into) -> io::Error { @@ -296,10 +318,10 @@ fn read_document(path: &Path) -> io::Result<(Option>, DocumentMut)> { fn parse_categories( document: &DocumentMut, mappings: &BTreeMap, -) -> io::Result { - let mut config = Config::default(); +) -> io::Result>> { + let mut parsed = BTreeMap::new(); let Some(item) = document.get("categories") else { - return Ok(config); + return Ok(parsed); }; let categories = item .as_table_like() @@ -321,17 +343,13 @@ fn parse_categories( normalized.push(channel); } } - if config - .categories - .insert(name.to_owned(), normalized) - .is_some() - { + if parsed.insert(name.to_owned(), normalized).is_some() { return Err(invalid(format!( "Duplicate category name after trimming: {name:?}" ))); } } - Ok(config) + Ok(parsed) } static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); @@ -430,6 +448,10 @@ mod tests { assert!(!path.parent().unwrap().exists()); let tags = BTreeMap::from([(" Music ".to_owned(), true)]); let config = set_channel_tags(&path, &tags, "@example", id).unwrap(); + assert_eq!( + config.channels["@example"], + format!("https://www.youtube.com/channel/{id}") + ); assert_eq!( config.categories["Music"], [format!("https://www.youtube.com/channel/{id}")] @@ -625,6 +647,49 @@ mod tests { ); } + #[test] + fn subscriptions_include_untagged_registry_and_manual_channels_after_tag_removal() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; + let url = format!("https://www.youtube.com/channel/{id}"); + let other_id = "UCabcdefghijklmnopqrstuv"; + let other_url = format!("https://www.youtube.com/channel/{other_id}"); + let registry = + format!("[channels]\n'https://youtube.com/@old' = '{id}'\n'@alias' = '{url}'\n"); + fs::write(&path, ®istry).unwrap(); + let config = load(&path).unwrap(); + assert!(config.categories.is_empty()); + assert_eq!(config.channels["@old"], url); + assert_eq!(config.subscriptions(), std::slice::from_ref(&url)); + + let config = set_channel_tags(&path, &BTreeMap::new(), "@untagged", other_id).unwrap(); + assert!(config.categories.is_empty()); + assert_eq!(config.subscriptions(), [url.clone(), other_url.clone()]); + assert_eq!(load(&path).unwrap().subscriptions(), config.subscriptions()); + + fs::write( + &path, + format!( + "{registry}[categories]\nTagged = ['@old', '@alias', '{url}', '{id}']\nManual = ['@RustVideos', 'https://youtube.com/@RustVideos', '{other_id}', '{other_url}']\n" + ), + ) + .unwrap(); + let expected = vec![ + "https://www.youtube.com/@RustVideos".to_owned(), + url.clone(), + other_url, + ]; + let config = load(&path).unwrap(); + assert_eq!(config.categories["Tagged"], std::slice::from_ref(&url)); + assert_eq!(config.subscriptions(), expected); + let tags = BTreeMap::from([("Tagged".to_owned(), false)]); + let config = set_channel_tags(&path, &tags, "@current", id).unwrap(); + assert!(config.categories["Tagged"].is_empty()); + assert_eq!(config.subscriptions(), expected); + assert_eq!(load(&path).unwrap().subscriptions(), expected); + } + #[test] fn paths_and_channel_addresses_are_strict() { let home = Some(OsString::from("/home/test")); diff --git a/src/main.rs b/src/main.rs index 0c860ba..d457525 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,9 @@ //! Missing handle/ID metadata or a conflicting mapping prevents saving rather than guessing. //! Normal-mode c edits memberships: j/k moves, Space toggles, Enter commits, Esc cancels. //! n prompts for a new tag; Enter stages it checked, Esc returns. Only list Enter writes the file. -//! Tab/Shift-Tab switch independent Search/tag views; r reloads config and refreshes. +//! Tab/Shift-Tab switch Search, the combined Feed, then independent tag views; r reloads/refreshes. +//! Feed includes saved `[channels]` entries, even untagged, plus direct tag entries, without duplicates. +//! Untagging is not unsubscribing: remove mappings and tag references from the file to unsubscribe. //! Feeds merge up to 20 videos per channel's /videos tab, ordered by approximate upload dates. //! IINA receives `--stdin`; other players must read standard input when passed `-`. //! Audio and video are streamed through a pipe, never saved to a video file. @@ -55,7 +57,7 @@ use youtube::{Job, Playback, Video}; #[command( version, about = "Search YouTube and stream videos to your chosen player", - after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search and new tag): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; t toggles thumbnail; c edits channel tags; q quits.\nTab/Shift-Tab switch Search/tag views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] optionally maps handles to canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel; other creators, omitted tags, and empty tag arrays are retained.\nUnmapped handles work directly. Existing URL entries still load.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nTAGS: existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a tag name edits text.\nCtrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\"" + after_help = "Requires yt-dlp, FFmpeg, and a player on PATH; thumbnails also use curl.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search and new tag): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; t toggles thumbnail; c edits channel tags; q quits.\nTab/Shift-Tab switch Search, Feed, then tag views; r reloads config and refreshes the current view.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, and saved registrations are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nAll feed views combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nTAGS: existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a tag name edits text.\nCtrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\"" )] struct Options { /// Player executable (IINA or a player supporting '-' for standard input). @@ -113,6 +115,7 @@ struct App { submitted_query: String, config_path: PathBuf, config: config::Config, + feed_channels: Vec, views: Vec, view_index: usize, category_dialog: Option, @@ -137,7 +140,8 @@ impl App { } else { Mode::Normal }; - let mut views = vec![View::new("Search".into())]; + let feed_channels = config.subscriptions(); + let mut views = vec![View::new("Search".into()), View::new("Feed".into())]; views.extend(config.categories.keys().cloned().map(View::new)); Self { options, @@ -146,6 +150,7 @@ impl App { submitted_query: String::new(), config_path, config, + feed_channels, views, view_index: 0, category_dialog: None, @@ -191,11 +196,15 @@ impl App { self.load_view(); } } else { - self.status = format!( - "{} videos in {}. Select a video to stream.", - self.views[index].videos.len(), - self.views[index].name - ); + self.status = if index == 1 && self.feed_channels.is_empty() { + "Feed has no channels. Press c on a search result to add one.".into() + } else { + format!( + "{} videos in {}. Select a video to stream.", + self.views[index].videos.len(), + self.views[index].name + ) + }; } } @@ -216,7 +225,11 @@ impl App { self.status = format!("Searching for {}...", self.submitted_query); Job::search(&self.submitted_query) } else { - let channels = &self.config.categories[&view.name]; + let channels = if self.view_index == 1 { + &self.feed_channels + } else { + &self.config.categories[&view.name] + }; if channels.is_empty() { view.loaded = true; self.status = format!( @@ -235,12 +248,21 @@ impl App { } fn apply_config(&mut self, config: config::Config) { - if config.categories == self.config.categories { + if config.categories == self.config.categories && config.channels == self.config.channels { return; } - let active_name = (self.view_index != 0).then(|| self.views[self.view_index].name.clone()); + let active_index = self.view_index; + let active_name = (active_index >= 2).then(|| self.views[active_index].name.clone()); + let feed_channels = config.subscriptions(); let mut old = std::mem::take(&mut self.views).into_iter(); self.views.push(old.next().expect("search view exists")); + let mut feed = old.next().expect("feed view exists"); + if feed_channels != self.feed_channels { + feed.loaded = false; + feed.videos.clear(); + feed.selection = TableState::default(); + } + self.views.push(feed); let mut old: Vec<_> = old.collect(); // ponytail: category lookup is quadratic; use a keyed map if hundreds of categories matter. for name in config.categories.keys() { @@ -256,10 +278,15 @@ impl App { } self.views.push(view); } - self.view_index = active_name - .and_then(|name| self.views.iter().skip(1).position(|view| view.name == name)) - .map(|index| index + 1) - .unwrap_or(0); + self.view_index = if active_index < 2 { + active_index + } else { + active_name + .and_then(|name| self.views.iter().skip(2).position(|view| view.name == name)) + .map(|index| index + 2) + .unwrap_or(0) + }; + self.feed_channels = feed_channels; self.config = config; self.job = None; self.description_open = false; @@ -1237,18 +1264,31 @@ mod tests { let rust = vec!["https://www.youtube.com/@RustVideos".into()]; app.apply_config(config::Config { categories: [("Rust".into(), rust.clone())].into(), + channels: BTreeMap::new(), }); app.views[0].selection.select(Some(1)); - app.views[1].videos = youtube::search_results(videos).unwrap(); + app.views[1].videos = youtube::feed_results(videos).unwrap(); app.views[1].loaded = true; app.views[1].selection.select(Some(0)); + app.views[2].videos = youtube::feed_results(videos).unwrap(); + app.views[2].loaded = true; + app.views[2].selection.select(Some(0)); app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 1); + assert_eq!(app.views[1].name, "Feed"); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 2); app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert_eq!(app.views[1].selection.selected(), Some(1)); + assert_eq!(app.views[2].selection.selected(), Some(1)); + assert_eq!(app.views[1].selection.selected(), Some(0)); + app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(app.view_index, 1); + assert_eq!(app.views[1].selection.selected(), Some(0)); app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); assert_eq!(app.view_index, 0); assert_eq!(app.views[0].selection.selected(), Some(1)); app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); app.apply_config(config::Config { categories: [ ( @@ -1258,13 +1298,15 @@ mod tests { ("Rust".into(), rust), ] .into(), + channels: BTreeMap::new(), }); - assert_eq!(app.view_index, 2); - assert_eq!(app.views[2].name, "Rust"); - assert_eq!(app.views[2].selection.selected(), Some(1)); - assert!(app.views[2].loaded); + assert_eq!(app.view_index, 3); + assert_eq!(app.views[3].name, "Rust"); + assert_eq!(app.views[3].selection.selected(), Some(1)); + assert!(app.views[3].loaded); app.apply_config(config::Config { categories: [("Rust".into(), vec![])].into(), + channels: BTreeMap::new(), }); assert_eq!(app.views[app.view_index].name, "Rust"); assert!(app.views[app.view_index].videos.is_empty()); @@ -1273,6 +1315,208 @@ mod tests { assert!(app.job.is_none()); } + #[test] + fn feed_cache_tracks_subscriptions_not_tag_memberships() { + let registered = "https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA"; + let direct = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; + let added = "https://www.youtube.com/channel/UCBR8-60-B28hp2BmDPdntcQ"; + let mut app = App::new( + Options::parse_from(["tubular", "rust"]), + PathBuf::new(), + config::Config { + channels: [("@RustVideos".into(), registered.into())].into(), + categories: [ + ("Direct".into(), vec![direct.into()]), + ("Tagged".into(), vec![registered.into()]), + ] + .into(), + }, + ); + let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"New","timestamp":200},{"id":"rQ_J9WH6CGk","title":"Old","timestamp":100}]}"#; + assert_eq!(app.feed_channels, vec![direct, registered]); + app.views[1].videos = youtube::feed_results(videos).unwrap(); + app.views[1].selection.select(Some(1)); + app.views[1].loaded = true; + app.views[3].videos = youtube::feed_results(videos).unwrap(); + app.views[3].selection.select(Some(0)); + app.views[3].loaded = true; + app.switch_view(1); + + app.apply_config(config::Config { + channels: app.config.channels.clone(), + categories: [ + ("Direct".into(), vec![direct.into()]), + ("Renamed".into(), vec![registered.into()]), + ("Tagged".into(), vec![]), + ] + .into(), + }); + assert_eq!(app.view_index, 1); + assert!(app.views[1].loaded); + assert_eq!(app.views[1].videos[1].id, "rQ_J9WH6CGk"); + assert_eq!(app.views[1].selection.selected(), Some(1)); + assert!(!app.views[4].loaded); + assert!(app.views[4].videos.is_empty()); + assert_eq!(app.views[4].selection.selected(), None); + + app.apply_config(config::Config { + channels: app.config.channels.clone(), + categories: [ + ("Direct".into(), vec![direct.into()]), + ("Renamed".into(), vec![]), + ("Tagged".into(), vec![]), + ] + .into(), + }); + assert_eq!(app.feed_channels, vec![direct, registered]); + assert!(app.views[1].loaded); + assert_eq!(app.views[1].videos.len(), 2); + assert_eq!(app.views[1].selection.selected(), Some(1)); + + let mut channels = app.config.channels.clone(); + channels.insert("@YouTube".into(), added.into()); + app.apply_config(config::Config { + channels, + categories: app.config.categories.clone(), + }); + assert_eq!(app.feed_channels, vec![direct, added, registered]); + assert!(!app.views[1].loaded); + assert!(app.views[1].videos.is_empty()); + assert_eq!(app.views[1].selection.selected(), None); + assert_eq!(app.view_index, 1); + assert!(app.job.is_none()); + + app.views[1].videos = youtube::feed_results(videos).unwrap(); + app.views[1].selection.select(Some(1)); + app.views[1].loaded = true; + let mut channels = app.config.channels.clone(); + channels.remove("@YouTube"); + app.apply_config(config::Config { + channels, + categories: app.config.categories.clone(), + }); + assert_eq!(app.feed_channels, vec![direct, registered]); + assert!(!app.views[1].loaded); + assert!(app.views[1].videos.is_empty()); + assert_eq!(app.views[1].selection.selected(), None); + assert_eq!(app.view_index, 1); + assert!(app.job.is_none()); + } + + #[test] + fn builtins_and_same_named_tags_keep_distinct_identities() { + let mut app = app(&["tubular", "rust"]); + app.apply_config(config::Config { + channels: [( + "@RustVideos".into(), + "https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".into(), + )] + .into(), + categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(), + }); + let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#; + for (index, view) in app.views.iter_mut().enumerate() { + view.videos = youtube::feed_results(videos).unwrap(); + view.loaded = true; + view.selection.select(Some(usize::from(index % 2 == 0))); + } + app.switch_view(1); + app.apply_config(config::Config { + channels: app.config.channels.clone(), + categories: [ + ("Alpha".into(), vec![]), + ("Feed".into(), vec![]), + ("Search".into(), vec![]), + ] + .into(), + }); + assert_eq!(app.view_index, 1); + assert_eq!(app.views[1].selection.selected(), Some(0)); + assert_eq!(app.views[3].name, "Feed"); + assert_eq!(app.views[3].selection.selected(), Some(1)); + assert_eq!(app.views[0].selection.selected(), Some(1)); + assert_eq!(app.views[4].selection.selected(), Some(0)); + + app.switch_view(0); + app.apply_config(config::Config { + channels: app.config.channels.clone(), + categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(), + }); + assert_eq!(app.view_index, 0); + app.switch_view(2); + app.apply_config(config::Config { + channels: app.config.channels.clone(), + categories: [ + ("Alpha".into(), vec![]), + ("Feed".into(), vec![]), + ("Search".into(), vec![]), + ] + .into(), + }); + assert_eq!(app.view_index, 3); + assert_eq!(app.views[3].selection.selected(), Some(1)); + + app.switch_view(4); + app.apply_config(config::Config { + channels: app.config.channels.clone(), + categories: [ + ("Alpha".into(), vec![]), + ("Beta".into(), vec![]), + ("Feed".into(), vec![]), + ("Search".into(), vec![]), + ] + .into(), + }); + assert_eq!(app.view_index, 5); + assert_eq!(app.views[5].selection.selected(), Some(0)); + app.apply_config(config::Config { + channels: app.config.channels.clone(), + categories: [ + ("Alpha".into(), vec![]), + ("Beta".into(), vec![]), + ("Feed".into(), vec![]), + ] + .into(), + }); + assert_eq!(app.view_index, 0); + assert_eq!(app.views[0].selection.selected(), Some(1)); + app.switch_view(4); + app.apply_config(config::Config { + channels: app.config.channels.clone(), + categories: [("Alpha".into(), vec![]), ("Beta".into(), vec![])].into(), + }); + assert_eq!(app.view_index, 0); + assert_eq!(app.views[1].name, "Feed"); + assert_eq!(app.views[1].selection.selected(), Some(0)); + assert!(app.job.is_none()); + } + + #[test] + fn empty_feed_is_informative_and_navigation_wraps_without_extraction() { + let mut app = app(&["tubular"]); + assert_eq!(app.views.len(), 2); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 0); + assert_eq!(app.mode, Mode::Insert); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL)); + assert_eq!(app.view_index, 1); + assert!(app.views[1].loaded); + assert!(app.views[1].videos.is_empty()); + assert_eq!(app.views[1].selection.selected(), None); + assert_eq!( + app.status, + "Feed has no channels. Press c on a search result to add one." + ); + assert!(app.job.is_none()); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 0); + app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(app.view_index, 1); + assert!(app.status.contains("Feed has no channels")); + assert!(app.job.is_none()); + } + #[test] fn channel_tags_stage_memberships_and_commit_only_from_the_list() { let path = std::env::temp_dir().join(format!(