feat: browse and save combined category feeds
This commit is contained in:
@@ -71,6 +71,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
|
|||||||
| `s` | Edit the selected channel's tags |
|
| `s` | Edit the selected channel's tags |
|
||||||
| `S` | Edit tags for all saved channels in the current category |
|
| `S` | Edit tags for all saved channels in the current category |
|
||||||
| `R` (Shift-R) | Rename the current category, including category-scoped collection views |
|
| `R` (Shift-R) | Rename the current category, including category-scoped collection views |
|
||||||
|
| `P` (Shift-P) | Save the current combined-category feed as a new category |
|
||||||
| `w` | Save the selected video to watch later (also works in previews) |
|
| `w` | Save the selected video to watch later (also works in previews) |
|
||||||
| `W` | Choose combined or category watch later from any video view or preview |
|
| `W` | Choose combined or category watch later from any video view or preview |
|
||||||
| `Alt-w` | Open the current category's watch later |
|
| `Alt-w` | Open the current category's watch later |
|
||||||
@@ -112,9 +113,21 @@ In terminals supporting bracketed paste, clipboard text is inserted in one opera
|
|||||||
|
|
||||||
### Category browser (`c`)
|
### Category browser (`c`)
|
||||||
|
|
||||||
- **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter browses the selected category; `p` toggles its persisted pin; `R` renames it; Esc closes.
|
- **Normal:** `j` / `k` or arrow keys navigate; Space checks/unchecks a category; `i` enters filter mode; Enter browses checked categories, or the hovered category if none are checked; `p` toggles its persisted pin; `R` renames it; Esc closes.
|
||||||
- **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses; Esc returns to normal mode without clearing the filter.
|
- **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses the chosen categories; Esc returns to normal mode without clearing the filter. Space is ordinary input here.
|
||||||
- Reopening the browser clears its filter. No matches makes browsing, pinning, and renaming a no-op.
|
- Checkboxes select categories for browsing; the dimmed `p` suffix marks pins independently. Hidden checks survive filtering. Enter still browses checked categories when there are no visible matches; without checks, an empty result makes browsing a no-op. Pinning, renaming, and toggling require a visible selected row.
|
||||||
|
|
||||||
|
### Combined category feeds
|
||||||
|
|
||||||
|
Check multiple categories in `c`, then press Enter to open their combined feed. One checked category opens its ordinary view; no checks retain the usual single-category shortcut. Esc discards the selector's staged choices. Reopening `c` from a combined feed prechecks its member categories.
|
||||||
|
|
||||||
|
The combined feed uses the shared temporary tab, replacing the prior unpinned category/channel/collection view. Channels and video IDs are deduplicated, and videos use the same newest-first ordering and infinite scrolling as other feeds. Back/forward navigation restores cached rows, selection, and pagination.
|
||||||
|
|
||||||
|
Press `P` to save the viewed union of channels under a new category name. Enter commits; Esc cancels; duplicate or invalid names leave the prompt open without overwriting anything. The new flat category becomes active without automatic pinning or re-fetching matching cached pages. Its memberships are an independent copy, not links to the original categories.
|
||||||
|
|
||||||
|
Renaming a member category updates the combined view and its history. Membership changes recompute the channel union, preserving cached rows when the resolved union is unchanged; deleting a member invalidates the combined view rather than silently widening it. Explicit refresh still reloads the active view from page one.
|
||||||
|
|
||||||
|
Combined feeds are not individual categories: `S`, `R`, and `Alt-w` / `Alt-v` require a single category. Save with `P` first to use those actions. Configure multi-selection with `selector.toggle` and saving with `category.save`; name entry uses the shared `input.*` controls.
|
||||||
|
|
||||||
### Saved channels (`u`)
|
### Saved channels (`u`)
|
||||||
|
|
||||||
@@ -178,7 +191,7 @@ Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC
|
|||||||
Video tables color channel names **cyan** when the creator belongs to at least one category. Untagged registrations keep the default text color; selected rows retain their black-on-cyan highlight. Membership is matched by channel ID or handle, not the displayed name, and updates after saving tags or reloading config.
|
Video tables color channel names **cyan** when the creator belongs to at least one category. Untagged registrations keep the default text color; selected rows retain their black-on-cyan highlight. Membership is matched by channel ID or handle, not the displayed name, and updates after saving tags or reloading config.
|
||||||
|
|
||||||
- **Search** and **Feed** are permanent tabs. Feed combines all `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates.
|
- **Search** and **Feed** are permanent tabs. Feed combines all `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates.
|
||||||
- Browsing an unpinned category, channel, watch-later list, or saved-video list uses one shared temporary tab; choosing another replaces it. Pinned categories keep their own tabs, marked with a dimmed `p`.
|
- Browsing an unpinned category, combined-category feed, channel, watch-later list, or saved-video list uses one shared temporary tab; choosing another replaces it. Pinned categories keep their own tabs, marked with a dimmed `p`.
|
||||||
- Removing a channel from a category does **not** unsubscribe it from Feed. To unsubscribe, remove its `[channels]` mappings and category references, then press `r`.
|
- Removing a channel from a category does **not** unsubscribe it from Feed. To unsubscribe, remove its `[channels]` mappings and category references, then press `r`.
|
||||||
- Temporary tabs do not survive restarting; saved categories and pins do. Hidden views retain loaded rows, selection, and pagination during the session.
|
- Temporary tabs do not survive restarting; saved categories and pins do. Hidden views retain loaded rows, selection, and pagination during the session.
|
||||||
|
|
||||||
|
|||||||
+217
@@ -384,6 +384,53 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_category(path: &Path, name: &str, channels: &[String]) -> io::Result<Config> {
|
||||||
|
let name = category_name(name)?;
|
||||||
|
let (original, mut document) = read_document(path)?;
|
||||||
|
let mappings = parse_channels(&document)?;
|
||||||
|
let mut categories = parse_categories(&document, &mappings)?;
|
||||||
|
let pinned_categories = parse_pins(&document)?;
|
||||||
|
let keys = keys::Bindings::from_toml(document.get("keys"))?;
|
||||||
|
if categories.contains_key(name) {
|
||||||
|
return Err(invalid(format!("Category already exists: {name:?}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut labels = BTreeMap::new();
|
||||||
|
// As in saved_channels, use the first mapped handle for each identity.
|
||||||
|
for (handle, identity) in &mappings {
|
||||||
|
labels.entry(identity.as_str()).or_insert(handle.as_str());
|
||||||
|
}
|
||||||
|
let identities: BTreeSet<_> = channels
|
||||||
|
.iter()
|
||||||
|
.map(|channel| resolve_channel(channel, &mappings))
|
||||||
|
.collect::<io::Result<_>>()?;
|
||||||
|
let mut entries = Array::new();
|
||||||
|
for identity in &identities {
|
||||||
|
let readable = labels.get(identity.as_str()).copied().unwrap_or_else(|| {
|
||||||
|
identity
|
||||||
|
.strip_prefix("https://www.youtube.com/")
|
||||||
|
.filter(|address| address.starts_with('@'))
|
||||||
|
.unwrap_or(identity)
|
||||||
|
});
|
||||||
|
entries.push(readable);
|
||||||
|
}
|
||||||
|
if !document.contains_key("categories") {
|
||||||
|
document.insert("categories", Item::Table(Table::new()));
|
||||||
|
}
|
||||||
|
document["categories"]
|
||||||
|
.as_table_like_mut()
|
||||||
|
.expect("categories were validated")
|
||||||
|
.insert(name, value(entries));
|
||||||
|
categories.insert(name.to_owned(), identities.into_iter().collect());
|
||||||
|
save(path, original.as_deref(), document.to_string().as_bytes())?;
|
||||||
|
Ok(Config {
|
||||||
|
channels: mappings,
|
||||||
|
categories,
|
||||||
|
pinned_categories,
|
||||||
|
keys,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn rename_category(path: &Path, old: &str, new: &str) -> io::Result<Config> {
|
pub fn rename_category(path: &Path, old: &str, new: &str) -> io::Result<Config> {
|
||||||
let old = category_name(old)?;
|
let old = category_name(old)?;
|
||||||
let new = category_name(new)?;
|
let new = category_name(new)?;
|
||||||
@@ -873,6 +920,9 @@ mod tests {
|
|||||||
let pin_error = set_category_pinned(&path, "B", true).unwrap_err();
|
let pin_error = set_category_pinned(&path, "B", true).unwrap_err();
|
||||||
assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData);
|
assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData);
|
||||||
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
|
let create_error = create_category(&path, "New", &expected).unwrap_err();
|
||||||
|
assert_eq!(create_error.kind(), io::ErrorKind::InvalidData);
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -989,6 +1039,173 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_creation_deduplicates_aliases_and_preserves_original_config() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
||||||
|
let url = format!("https://www.youtube.com/channel/{id}");
|
||||||
|
let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [' Source '] # favorite\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@zeta' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n'@alpha' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\n# category note\n' Source ' = [\n '@zeta', # entry note\n '@manual',\n '@manual',\n] # array note\nEmpty = []\n";
|
||||||
|
fs::write(&path, original).unwrap();
|
||||||
|
let before = load(&path).unwrap();
|
||||||
|
let created = create_category(
|
||||||
|
&path,
|
||||||
|
" Combined ",
|
||||||
|
&[
|
||||||
|
id.to_owned(),
|
||||||
|
url.clone(),
|
||||||
|
"@zeta".to_owned(),
|
||||||
|
"https://youtube.com/@alpha".to_owned(),
|
||||||
|
"@manual".to_owned(),
|
||||||
|
"https://www.youtube.com/@manual".to_owned(),
|
||||||
|
"UCabcdefghijklmnopqrstuv".to_owned(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
created.categories["Combined"],
|
||||||
|
[
|
||||||
|
"https://www.youtube.com/@manual".to_owned(),
|
||||||
|
url,
|
||||||
|
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(created.channels, before.channels);
|
||||||
|
assert_eq!(created.categories["Source"], before.categories["Source"]);
|
||||||
|
assert_eq!(created.categories["Empty"], before.categories["Empty"]);
|
||||||
|
assert_eq!(created.pinned_categories, before.pinned_categories);
|
||||||
|
let contents = fs::read_to_string(&path).unwrap();
|
||||||
|
let mut document = contents.parse::<DocumentMut>().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
document["categories"]["Combined"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.as_str().unwrap())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
"@manual",
|
||||||
|
"@alpha",
|
||||||
|
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
document["categories"]
|
||||||
|
.as_table_like_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("Combined");
|
||||||
|
assert_eq!(document.to_string(), original);
|
||||||
|
assert_eq!(load(&path).unwrap().categories, created.categories);
|
||||||
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_creation_uses_latest_mappings_and_supports_inline_unicode_and_empty() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let original = "categories = { Source = ['@first', '@first'], Empty = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\nchannels = { '@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' }\n";
|
||||||
|
fs::write(&path, original).unwrap();
|
||||||
|
let opened = load(&path).unwrap();
|
||||||
|
let latest = original.replace("UC4QobU6STFB0P71PMvOGN5A", "UCabcdefghijklmnopqrstuv");
|
||||||
|
fs::write(&path, &latest).unwrap();
|
||||||
|
let created = create_category(
|
||||||
|
&path,
|
||||||
|
" 日本語 🎵 ",
|
||||||
|
&["@mapped".to_owned(), "UCabcdefghijklmnopqrstuv".to_owned()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(created.channels, opened.channels);
|
||||||
|
assert_eq!(
|
||||||
|
created.categories["日本語 🎵"],
|
||||||
|
["https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"]
|
||||||
|
);
|
||||||
|
assert_eq!(created.pinned_categories, ["Source"]);
|
||||||
|
let contents = fs::read_to_string(&path).unwrap();
|
||||||
|
let document = contents.parse::<DocumentMut>().unwrap();
|
||||||
|
let before_document = latest.parse::<DocumentMut>().unwrap();
|
||||||
|
assert!(document["categories"].is_inline_table());
|
||||||
|
assert_eq!(
|
||||||
|
document["categories"]["日本語 🎵"][0].as_str(),
|
||||||
|
Some("@mapped")
|
||||||
|
);
|
||||||
|
for section in ["settings", "channels"] {
|
||||||
|
assert_eq!(
|
||||||
|
document[section].to_string(),
|
||||||
|
before_document[section].to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for name in ["Source", "Empty"] {
|
||||||
|
assert_eq!(
|
||||||
|
document["categories"][name].to_string(),
|
||||||
|
before_document["categories"][name].to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(contents.contains("# categories note"));
|
||||||
|
let empty = create_category(&path, "New empty", &[]).unwrap();
|
||||||
|
assert!(empty.categories["New empty"].is_empty());
|
||||||
|
assert_eq!(empty.pinned_categories, ["Source"]);
|
||||||
|
let missing = directory.0.join("nested/config.toml");
|
||||||
|
let empty = create_category(&missing, "Empty", &[]).unwrap();
|
||||||
|
assert!(empty.categories["Empty"].is_empty());
|
||||||
|
assert!(empty.channels.is_empty());
|
||||||
|
assert!(empty.pinned_categories.is_empty());
|
||||||
|
assert_eq!(load(&missing).unwrap().categories, empty.categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_creation_rejects_invalid_names_channels_and_configs_without_writes() {
|
||||||
|
let directory = TestDirectory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
let original = "[categories]\n' Source ' = ['@manual']\nEmpty = []\n";
|
||||||
|
fs::write(&path, original).unwrap();
|
||||||
|
for name in [
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"Bad\nName",
|
||||||
|
"Bad\u{7f}Name",
|
||||||
|
"Source",
|
||||||
|
" Source ",
|
||||||
|
"Empty",
|
||||||
|
] {
|
||||||
|
assert!(create_category(&path, name, &[]).is_err(), "{name:?}");
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
|
}
|
||||||
|
// Even an identical membership snapshot must not overwrite a category.
|
||||||
|
assert!(create_category(&path, "Source", &["@manual".to_owned()]).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
|
for channel in [
|
||||||
|
"",
|
||||||
|
"bad",
|
||||||
|
"@bad handle",
|
||||||
|
"@manual\n",
|
||||||
|
"https://example.com/@manual",
|
||||||
|
"https://www.youtube.com/@manual/videos",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
create_category(&path, "New", &["@valid".to_owned(), channel.to_owned()]).is_err(),
|
||||||
|
"{channel:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||||
|
}
|
||||||
|
for malformed in [
|
||||||
|
"[categories\n",
|
||||||
|
"categories = 3\n",
|
||||||
|
"[categories]\nSource = [7]\n",
|
||||||
|
"[categories]\nSource = []\n' Source ' = []\n",
|
||||||
|
"[channels]\n'@mapped' = '@other'\n",
|
||||||
|
"[settings]\npinned_categories = [7]\n",
|
||||||
|
"[keys.app]\nunknown = ['q']\n",
|
||||||
|
] {
|
||||||
|
fs::write(&path, malformed).unwrap();
|
||||||
|
assert!(create_category(&path, "New", &[]).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), malformed.as_bytes());
|
||||||
|
}
|
||||||
|
let missing = directory.0.join("missing/config.toml");
|
||||||
|
assert!(create_category(&missing, "Bad\nName", &[]).is_err());
|
||||||
|
assert!(create_category(&missing, "New", &["@bad handle".to_owned()]).is_err());
|
||||||
|
assert!(!missing.parent().unwrap().exists());
|
||||||
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn category_rename_preserves_memberships_pins_comments_and_section_order() {
|
fn category_rename_preserves_memberships_pins_comments_and_section_order() {
|
||||||
let directory = TestDirectory::new();
|
let directory = TestDirectory::new();
|
||||||
|
|||||||
+23
-7
@@ -22,7 +22,7 @@ pub enum Context {
|
|||||||
TagList,
|
TagList,
|
||||||
TagFilter,
|
TagFilter,
|
||||||
TagInput,
|
TagInput,
|
||||||
RenameInput,
|
CategoryNameInput,
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ const ALL: &[Context] = &[
|
|||||||
Context::TagList,
|
Context::TagList,
|
||||||
Context::TagFilter,
|
Context::TagFilter,
|
||||||
Context::TagInput,
|
Context::TagInput,
|
||||||
Context::RenameInput,
|
Context::CategoryNameInput,
|
||||||
Context::Help,
|
Context::Help,
|
||||||
];
|
];
|
||||||
const VIEW: &[Context] = &[
|
const VIEW: &[Context] = &[
|
||||||
@@ -60,7 +60,7 @@ const TEXT: &[Context] = &[
|
|||||||
Context::SelectorInput,
|
Context::SelectorInput,
|
||||||
Context::TagFilter,
|
Context::TagFilter,
|
||||||
Context::TagInput,
|
Context::TagInput,
|
||||||
Context::RenameInput,
|
Context::CategoryNameInput,
|
||||||
];
|
];
|
||||||
const NON_TEXT: &[Context] = &[
|
const NON_TEXT: &[Context] = &[
|
||||||
Context::Normal,
|
Context::Normal,
|
||||||
@@ -105,7 +105,7 @@ actions! {
|
|||||||
Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"];
|
Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"];
|
||||||
Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::Help], ["q"];
|
Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::Help], ["q"];
|
||||||
Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"];
|
Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"];
|
||||||
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or rename category", TEXT, ["enter"];
|
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or save category name", TEXT, ["enter"];
|
||||||
InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"];
|
InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"];
|
||||||
DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"];
|
DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"];
|
||||||
DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"];
|
DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"];
|
||||||
@@ -130,6 +130,7 @@ actions! {
|
|||||||
ChannelTags, "channel.tags", "Edit the selected channel's category tags", VIEW, ["s"];
|
ChannelTags, "channel.tags", "Edit the selected channel's category tags", VIEW, ["s"];
|
||||||
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
|
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
|
||||||
CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"];
|
CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"];
|
||||||
|
CategorySave, "category.save", "Save the combined category view as a new category", VIEW, ["P"];
|
||||||
WatchLaterSave, "video.watch_later.save", "Save the selected video to watch later", VIEW, ["w"];
|
WatchLaterSave, "video.watch_later.save", "Save the selected video to watch later", VIEW, ["w"];
|
||||||
WatchLaterBrowse, "view.watch_later.browse", "Choose watch later across all categories or one category", VIEW, ["W"];
|
WatchLaterBrowse, "view.watch_later.browse", "Choose watch later across all categories or one category", VIEW, ["W"];
|
||||||
WatchLaterBrowseCategory, "view.watch_later.category", "Browse the current category's watch later", VIEW, ["alt+w"];
|
WatchLaterBrowseCategory, "view.watch_later.category", "Browse the current category's watch later", VIEW, ["alt+w"];
|
||||||
@@ -146,6 +147,7 @@ actions! {
|
|||||||
SelectorEdit, "selector.edit", "Edit the category, channel, or collection filter", &[Context::SelectorNormal], ["i"];
|
SelectorEdit, "selector.edit", "Edit the category, channel, or collection filter", &[Context::SelectorNormal], ["i"];
|
||||||
SelectorOpen, "selector.open", "Open the selected category, channel, or collection", &[Context::SelectorNormal], ["enter"];
|
SelectorOpen, "selector.open", "Open the selected category, channel, or collection", &[Context::SelectorNormal], ["enter"];
|
||||||
SelectorPin, "selector.pin", "Toggle category pinning (category picker only)", &[Context::SelectorNormal], ["p"];
|
SelectorPin, "selector.pin", "Toggle category pinning (category picker only)", &[Context::SelectorNormal], ["p"];
|
||||||
|
SelectorToggle, "selector.toggle", "Check a category for combined browsing (category picker only)", &[Context::SelectorNormal], ["space"];
|
||||||
SelectorClose, "selector.close", "Close the category, channel, or collection picker", &[Context::SelectorNormal], ["escape"];
|
SelectorClose, "selector.close", "Close the category, channel, or collection picker", &[Context::SelectorNormal], ["escape"];
|
||||||
TagNext, "tags.select.next", "Select the next category tag", &[Context::TagList], ["j", "down"];
|
TagNext, "tags.select.next", "Select the next category tag", &[Context::TagList], ["j", "down"];
|
||||||
TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"];
|
TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"];
|
||||||
@@ -486,6 +488,10 @@ impl Bindings {
|
|||||||
&[CategoryRename],
|
&[CategoryRename],
|
||||||
"Rename the current category (including scoped collections)",
|
"Rename the current category (including scoped collections)",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
&[CategorySave],
|
||||||
|
"Save a combined category view as a new category",
|
||||||
|
),
|
||||||
(&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"),
|
(&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"),
|
||||||
(&[ViewPrevious], "Back (preview close takes priority)"),
|
(&[ViewPrevious], "Back (preview close takes priority)"),
|
||||||
(&[ViewNext], "Forward after going back"),
|
(&[ViewNext], "Forward after going back"),
|
||||||
@@ -571,7 +577,14 @@ impl Bindings {
|
|||||||
&[SelectorInputNext, SelectorInputPrevious],
|
&[SelectorInputNext, SelectorInputPrevious],
|
||||||
"Move while filtering",
|
"Move while filtering",
|
||||||
),
|
),
|
||||||
(&[SelectorOpen, InputSubmit], "Browse the selected match"),
|
(
|
||||||
|
&[SelectorOpen, InputSubmit],
|
||||||
|
"Browse checked categories, or the selected match",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[SelectorToggle],
|
||||||
|
"Check categories for a combined feed (NORMAL only)",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
&[SelectorPin],
|
&[SelectorPin],
|
||||||
"Pin / unpin a category (category picker only)",
|
"Pin / unpin a category (category picker only)",
|
||||||
@@ -610,9 +623,12 @@ impl Bindings {
|
|||||||
&[
|
&[
|
||||||
(
|
(
|
||||||
&[InputSubmit],
|
&[InputSubmit],
|
||||||
"Submit search / stage tag / commit category rename",
|
"Submit search / stage tag / save category name",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[InputCancel],
|
||||||
|
"Leave INSERT / abandon tag or category name",
|
||||||
),
|
),
|
||||||
(&[InputCancel], "Leave INSERT / abandon tag name or rename"),
|
|
||||||
(&[DeleteCharacter], "Delete one character"),
|
(&[DeleteCharacter], "Delete one character"),
|
||||||
(&[DeleteWord], "Delete previous word"),
|
(&[DeleteWord], "Delete previous word"),
|
||||||
(&[DeleteLine], "Clear the line"),
|
(&[DeleteLine], "Clear the line"),
|
||||||
|
|||||||
+886
-161
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user