feat: rename categories from views and selectors

This commit is contained in:
2026-10-11 23:10:15 +02:00
parent aa66ac9d7f
commit ff7590dfa7
4 changed files with 1065 additions and 19 deletions
+18 -7
View File
@@ -70,6 +70,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| `U` (Shift-U) | Open the selected video's channel |
| `s` | Edit the selected channel's tags |
| `S` | Edit tags for all saved channels in the current category |
| `R` (Shift-R) | Rename the current category, including category-scoped collection views |
| `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 |
| `Alt-w` | Open the current category's watch later |
@@ -95,12 +96,12 @@ The full-description view scrolls with ↓ / ↑, Ctrl-J / Ctrl-K, or PageDown /
### Text input
These shortcuts work in search, category/channel/tag-filter, and new-tag fields:
These shortcuts work in search, category/channel/tag-filter, new-tag, and category-rename fields:
| Key | Action |
| --- | --- |
| Enter | Submit the search, browse the selected category/channel, leave tag-filter mode, or stage the new tag |
| Esc | Leave insert mode or abandon the new-tag name |
| Enter | Submit the search, browse the selected category/channel, leave tag-filter mode, stage a new tag, or commit a rename |
| Esc | Leave insert mode or abandon the new-tag / rename prompt |
| Backspace | Delete one character |
| Option/Alt-Backspace or Ctrl-W | Delete the previous whitespace-delimited word |
| Cmd/Super-Backspace or Ctrl-U | Clear the line |
@@ -111,9 +112,9 @@ In terminals supporting bracketed paste, clipboard text is inserted in one opera
### Category browser (`c`)
- **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter browses the selected category; `p` toggles its persisted pin; Esc closes.
- **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.
- **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.
- Reopening the browser clears its filter. No matches makes browsing and pinning a no-op.
- Reopening the browser clears its filter. No matches makes browsing, pinning, and renaming a no-op.
### Saved channels (`u`)
@@ -127,15 +128,25 @@ Press **`U` (Shift-U)** on a video to browse its creator directly, including cha
### Channel tags (`s`, or `S` for bulk editing)
- **Normal:** `j` / `k` or arrow keys navigate; Space toggles a tag; `i` edits the filter.
- **Normal:** `j` / `k` or arrow keys navigate; Space toggles a tag; `i` edits the filter; `R` renames the selected tag.
- **Insert:** type a case-insensitive substring to filter live; arrow keys or Ctrl-J / Ctrl-K navigate matches. Enter or Esc returns to normal mode without saving or clearing the filter.
- Filtering only hides rows: checked and unchecked staged memberships outside the filter are retained. With no matches, navigation and toggling do nothing. Reopening the dialog clears its filter.
- `n` prompts for a new tag name. Enter stages it checked, clears the filter, and selects the new tag; Esc abandons the name.
- Enter **in the normal-mode tag list** saves all staged memberships, including hidden tags. Esc cancels without writing. These controls apply to both single-channel and bulk tagging.
- Enter **in the normal-mode tag list** saves all staged memberships, including hidden tags. Esc cancels those staged changes; it does not undo a committed rename of an existing category. These controls apply to both single-channel and bulk tagging.
- Filter actions are configurable as `tags.edit`, `tags.input.next`, and `tags.input.previous`; text editing uses the shared `input.*` actions.
Bulk editing applies the exact checked tag set to **every saved channel in the current category**, not only channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Review the checked set before saving: unchecked tags are removed from all targets. Other channels, saved registrations, empty categories, pins, and comments are preserved.
### Renaming categories (`R`)
In normal mode, press `R` (Shift-R) to rename the active category, or the hovered category in a category/collection selector or single-channel/bulk tag list. Combined collection entries and channel rows cannot be renamed.
The floating prompt starts with the current name. Edit it with the shared text controls (`Ctrl-U` clears it), then press Enter to commit or Esc to cancel. Names are trimmed and must be nonempty, free of control characters, and distinct from existing or staged category names. Errors leave the prompt open without changing the file. If the source category changed externally, close dialogs and reload config with `r` before retrying.
Renaming an existing category saves its identity immediately while preserving channel memberships, pins, comments, cached videos, selection, pagination, watch filters, and back/forward history. In tag dialogs, checkbox changes remain staged until the main tag-list Enter; cancelling them does not undo the rename. A newly staged, unsaved tag stays local when renamed and reaches disk only when the tags are saved.
Configure the shortcut with `category.rename`; the prompt uses the shared `input.*` actions. `R` remains ordinary text while editing search or filters.
## Subscriptions and feeds
Configuration lives at:
+212
View File
@@ -384,6 +384,83 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
})
}
pub fn rename_category(path: &Path, old: &str, new: &str) -> io::Result<Config> {
let old = category_name(old)?;
let new = category_name(new)?;
let (original, mut document) = read_document(path)?;
let channels = parse_channels(&document)?;
let categories = parse_categories(&document, &channels)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
if !categories.contains_key(old) {
return Err(invalid(format!("Unknown source category: {old:?}")));
}
if old == new {
return Ok(Config {
channels,
categories,
pinned_categories,
keys,
});
}
if categories.contains_key(new) {
return Err(invalid(format!("Category already exists: {new:?}")));
}
// KeyMut only edits formatting. Move entries through formatted insertion to
// rename the key without changing its position or any membership formatting.
match &mut document["categories"] {
Item::Table(table) => {
let names: Vec<_> = table.iter().map(|(name, _)| name.to_owned()).collect();
for name in names {
let (mut key, item) = table.remove_entry(&name).expect("category was validated");
if key.get().trim() == old {
key = toml_edit::Key::new(new)
.with_leaf_decor(key.leaf_decor().clone())
.with_dotted_decor(key.dotted_decor().clone());
}
table.insert_formatted(&key, item);
}
}
Item::Value(toml_edit::Value::InlineTable(table)) => {
let names: Vec<_> = table.iter().map(|(name, _)| name.to_owned()).collect();
for name in names {
let (mut key, item) = table.remove_entry(&name).expect("category was validated");
if key.get().trim() == old {
key = toml_edit::Key::new(new)
.with_leaf_decor(key.leaf_decor().clone())
.with_dotted_decor(key.dotted_decor().clone());
}
table.insert_formatted(&key, item);
}
}
_ => unreachable!("categories were validated"),
}
if let Some(array) = document
.get_mut("settings")
.and_then(Item::as_table_like_mut)
.and_then(|settings| settings.get_mut("pinned_categories"))
.and_then(Item::as_array_mut)
{
for pin in array.iter_mut() {
if pin.as_str().expect("pin was validated").trim() == old {
let decor = pin.decor().clone();
*pin = new.into();
*pin.decor_mut() = decor;
}
}
}
let categories = parse_categories(&document, &channels)?;
let pinned_categories = parse_pins(&document)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(Config {
channels,
categories,
pinned_categories,
keys,
})
}
fn parse_pins(document: &DocumentMut) -> io::Result<Vec<String>> {
if document.contains_key("pinned_categories") {
return Err(invalid("Move top-level pinned_categories into [settings]"));
@@ -912,6 +989,141 @@ mod tests {
}
}
#[test]
fn category_rename_preserves_memberships_pins_comments_and_section_order() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [\n ' Source ', # favorite\n 'Keep',\n] # pin note\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n[categories]\nBefore = []\n# category note\n' Source ' = [\n '@mapped', # entry note\n '@manual',\n '@manual',\n] # array note\nKeep = ['@manual']\n";
fs::write(&path, original).unwrap();
let before = load(&path).unwrap();
let before_document = original.parse::<DocumentMut>().unwrap();
let renamed = rename_category(&path, " Source ", " Renamed group ").unwrap();
assert!(!renamed.categories.contains_key("Source"));
assert_eq!(
renamed.categories["Renamed group"],
before.categories["Source"]
);
assert_eq!(renamed.categories["Keep"], before.categories["Keep"]);
assert_eq!(renamed.channels, before.channels);
assert_eq!(renamed.subscriptions(), before.subscriptions());
assert_eq!(renamed.pinned_categories, ["Renamed group", "Keep"]);
let contents = fs::read_to_string(&path).unwrap();
let document = contents.parse::<DocumentMut>().unwrap();
assert_eq!(
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
["other", "settings", "keys", "channels", "categories"]
);
assert_eq!(
document["categories"]
.as_table_like()
.unwrap()
.iter()
.map(|(name, _)| name)
.collect::<Vec<_>>(),
["Before", "Renamed group", "Keep"]
);
assert_eq!(
document["categories"]["Renamed group"].to_string(),
before_document["categories"][" Source "].to_string()
);
for section in ["other", "keys", "channels"] {
assert_eq!(
document[section].to_string(),
before_document[section].to_string()
);
}
assert_eq!(document["settings"]["player"].as_str(), Some("mpv"));
for comment in [
"# manual preferences",
"# favorite",
"# pin note",
"# category note",
"# entry note",
"# array note",
"# key note",
"# mapping note",
] {
assert!(contents.contains(comment), "lost {comment}");
}
assert_eq!(load(&path).unwrap().categories, renamed.categories);
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_rename_preserves_inline_tables_and_reads_latest_memberships() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let original = "categories = { Before = [], Source = ['@first', '@first'], 'Keep' = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\n";
fs::write(&path, original).unwrap();
let opened = load(&path).unwrap();
let latest = original.replace("'@first', '@first'", "'@first', '@late', '@first'");
fs::write(&path, &latest).unwrap();
let renamed = rename_category(&path, "Source", "Renamed").unwrap();
assert_eq!(opened.categories["Source"].len(), 1);
assert_eq!(
renamed.categories["Renamed"],
[
"https://www.youtube.com/@first",
"https://www.youtube.com/@late",
]
);
assert!(!renamed.categories.contains_key("Source"));
assert_eq!(renamed.pinned_categories, ["Renamed"]);
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(
contents,
latest
.replace("Source =", "Renamed =")
.replace("'Source'", "\"Renamed\"")
);
let document = contents.parse::<DocumentMut>().unwrap();
assert!(document["categories"].is_inline_table());
assert_eq!(document["settings"]["custom"].as_integer(), Some(42));
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_rename_rejects_invalid_missing_or_conflicting_names_without_writes() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let original = "[settings]\npinned_categories = ['Source', 'Keep']\n[categories]\n' Source ' = ['@manual']\nKeep = []\n";
fs::write(&path, original).unwrap();
for (old, new) in [
("Source", ""),
("Source", " \t "),
("Source", "Bad\nName"),
("", "New"),
("Missing", "New"),
("Missing", "Missing"),
("Source", "Keep"),
("Source", " Keep "),
] {
assert!(
rename_category(&path, old, new).is_err(),
"{old:?} -> {new:?}"
);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
}
let unchanged = rename_category(&path, " Source ", "Source").unwrap();
assert_eq!(unchanged.categories, load(&path).unwrap().categories);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
for invalid in [
"[settings]\npinned_categories = [7]\n[categories]\nSource = []\n",
"[settings]\npinned_categories = ['Source', 'New']\n[categories]\nSource = []\n",
"[keys.global]\nunknown = ['q']\n[categories]\nSource = []\n",
"[categories]\nSource = [7]\n",
"[categories]\nSource = []\n' Source ' = []\n",
] {
fs::write(&path, invalid).unwrap();
assert!(rename_category(&path, "Source", "New").is_err());
assert_eq!(fs::read(&path).unwrap(), invalid.as_bytes());
}
let missing = directory.0.join("missing/config.toml");
assert!(rename_category(&missing, "Source", "New").is_err());
assert!(!missing.parent().unwrap().exists());
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() {
let directory = TestDirectory::new();
+22 -3
View File
@@ -22,6 +22,7 @@ pub enum Context {
TagList,
TagFilter,
TagInput,
RenameInput,
Help,
}
@@ -38,6 +39,7 @@ const ALL: &[Context] = &[
Context::TagList,
Context::TagFilter,
Context::TagInput,
Context::RenameInput,
Context::Help,
];
const VIEW: &[Context] = &[
@@ -58,6 +60,7 @@ const TEXT: &[Context] = &[
Context::SelectorInput,
Context::TagFilter,
Context::TagInput,
Context::RenameInput,
];
const NON_TEXT: &[Context] = &[
Context::Normal,
@@ -102,7 +105,7 @@ actions! {
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"];
Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"];
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter or stage new tag", TEXT, ["enter"];
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or rename category", TEXT, ["enter"];
InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"];
DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"];
DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"];
@@ -126,6 +129,7 @@ actions! {
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
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"];
CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"];
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"];
WatchLaterBrowseCategory, "view.watch_later.category", "Browse the current category's watch later", VIEW, ["alt+w"];
@@ -478,6 +482,10 @@ impl Bindings {
(&[CategoryBrowse], "Choose a saved category"),
(&[ChannelTags], "Edit the selected channel's tags"),
(&[CategoryTags], "Edit all channels in a category"),
(
&[CategoryRename],
"Rename the current category (including scoped collections)",
),
(&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"),
(&[ViewPrevious], "Back (preview close takes priority)"),
(&[ViewNext], "Forward after going back"),
@@ -568,6 +576,10 @@ impl Bindings {
&[SelectorPin],
"Pin / unpin a category (category picker only)",
),
(
&[CategoryRename],
"Rename the selected category, not combined / channel rows",
),
(&[InputCancel, SelectorClose], "INSERT → NORMAL → close"),
],
),
@@ -583,6 +595,10 @@ impl Bindings {
),
(&[TagToggle], "Toggle a tag"),
(&[TagNew], "Enter a new tag name"),
(
&[CategoryRename],
"Rename a tag; existing category renames save immediately",
),
(&[TagSave], "Save tags"),
(&[InputSubmit], "Stage a new tag name"),
(&[TagCancel], "Cancel tag changes"),
@@ -592,8 +608,11 @@ impl Bindings {
(
"TEXT INPUT",
&[
(&[InputSubmit], "Submit; search returns to NORMAL"),
(&[InputCancel], "Leave INSERT / abandon tag name"),
(
&[InputSubmit],
"Submit search / stage tag / commit category rename",
),
(&[InputCancel], "Leave INSERT / abandon tag name or rename"),
(&[DeleteCharacter], "Delete one character"),
(&[DeleteWord], "Delete previous word"),
(&[DeleteLine], "Clear the line"),
+813 -9
View File
File diff suppressed because one or more lines are too long