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
+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