feat: allow renaming sidebar favorites to custom names (#1911)

Closes #1474 



https://github.com/user-attachments/assets/69fb51ca-225c-43c9-91c9-8f48375e39c4

- [x] I have disclosed use of any AI generated code in my commit
messages.
- If you are using an LLM, and do not fully understand the changes it is
making to the code base, do not create a PR.
- In our experience, AI generated code often results in overly complex
code that lacks enough context for a proper fix or feature inclusion.
This results in considerably longer code reviews. Due to this, AI
authored or partially authored PRs may be closed without comment.
- [x] I understand these changes in full and will be able to respond to
review comments.
- [x] My change is accurately described in the commit message.
- [x] My contribution is tested and working as described.
- [x] I have read the [Developer Certificate of
Origin](https://developercertificate.org/) and certify my contribution
under its conditions.
This commit is contained in:
Jeremy Soller 2026-08-21 07:20:21 -06:00 • committed by GitHub
commit 7cebbef82f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 218 additions and 27 deletions

View file

@ -118,6 +118,9 @@ rename-file = Rename file
rename-folder = Rename folder rename-folder = Rename folder
rename-confirm = Rename rename-confirm = Rename
## Change Sidebar Label Dialog
sidebar-label = Sidebar label
## Replace Dialog ## Replace Dialog
replace = Replace replace = Replace
replace-title = "{$filename}" already exists in this location replace-title = "{$filename}" already exists in this location
@ -341,6 +344,7 @@ move-to = Move to...
move-to-trash = Move to trash move-to-trash = Move to trash
restore-from-trash = Restore from trash restore-from-trash = Restore from trash
remove-from-sidebar = Remove from sidebar remove-from-sidebar = Remove from sidebar
change-sidebar-label = Change sidebar label
sort-by-name = Sort by name sort-by-name = Sort by name
sort-by-modified = Sort by modified sort-by-modified = Sort by modified
sort-by-size = Sort by size sort-by-size = Sort by size

View file

@ -109,6 +109,10 @@ permanently-delete-warning = Deseja realmente excluir permanentemente { $target
rename-file = Renomear arquivo rename-file = Renomear arquivo
rename-folder = Renomear pasta rename-folder = Renomear pasta
## Change Sidebar Label Dialog
sidebar-label = Rótulo da barra lateral
## Replace Dialog ## Replace Dialog
replace = Substituir replace = Substituir
@ -365,6 +369,7 @@ open-in-terminal = Abrir no terminal
move-to-trash = Mover para a lixeira move-to-trash = Mover para a lixeira
restore-from-trash = Restaurar da lixeira restore-from-trash = Restaurar da lixeira
remove-from-sidebar = Remover da barra lateral remove-from-sidebar = Remover da barra lateral
change-sidebar-label = Alterar rótulo na barra lateral
sort-by-name = Ordenar por nome sort-by-name = Ordenar por nome
sort-by-modified = Ordenar por data de modificação sort-by-modified = Ordenar por data de modificação
sort-by-size = Ordenar por tamanho sort-by-size = Ordenar por tamanho

View file

@ -321,6 +321,7 @@ pub enum NavMenuAction {
Preview(segmented_button::Entity), Preview(segmented_button::Entity),
RunContextAction(segmented_button::Entity, usize), RunContextAction(segmented_button::Entity, usize),
RemoveFromSidebar(segmented_button::Entity), RemoveFromSidebar(segmented_button::Entity),
ChangeSidebarLabel(segmented_button::Entity),
} }
impl MenuAction for NavMenuAction { impl MenuAction for NavMenuAction {
@ -575,6 +576,10 @@ pub enum DialogPage {
DeleteTrash { DeleteTrash {
items: Vec<TrashItem>, items: Vec<TrashItem>,
}, },
ChangeSidebarLabel {
entity: Entity,
label: String,
},
RenameItem { RenameItem {
from: PathBuf, from: PathBuf,
parent: PathBuf, parent: PathBuf,
@ -1769,15 +1774,9 @@ impl App {
for (favorite_i, favorite) in self.config.favorites.iter().enumerate() { for (favorite_i, favorite) in self.config.favorites.iter().enumerate() {
if let Some(path) = favorite.path_opt() { if let Some(path) = favorite.path_opt() {
let name = if matches!(favorite, Favorite::Home) { let name = favorite
fl!("home") .display_name()
} else if let Favorite::Network { name, .. } = favorite { .unwrap_or_else(|| fl!("filesystem"));
name.clone()
} else if let Some(file_name) = path.file_name().and_then(|x| x.to_str()) {
file_name.to_string()
} else {
fl!("filesystem")
};
nav_model = nav_model.insert(move |b| { nav_model = nav_model.insert(move |b| {
b.text(name.clone()) b.text(name.clone())
.icon( .icon(
@ -2290,15 +2289,33 @@ impl App {
.favorites .favorites
.iter() .iter()
.map(|favorite| { .map(|favorite| {
if let Favorite::Path(path) = favorite { match favorite {
for (from, to) in path_changes.iter().map(|(f, t)| (f.as_ref(), t.as_ref())) { Favorite::Path(path) => {
if path.starts_with(from) for (from, to) in path_changes.iter().map(|(f, t)| (f.as_ref(), t.as_ref()))
&& let Ok(relative) = path.strip_prefix(from)
{ {
favorites_changed = true; if path.starts_with(from)
return Favorite::from_path(to.join(relative)); && let Ok(relative) = path.strip_prefix(from)
{
favorites_changed = true;
return Favorite::from_path(to.join(relative));
}
} }
} }
Favorite::Named { path, name } => {
for (from, to) in path_changes.iter().map(|(f, t)| (f.as_ref(), t.as_ref()))
{
if path.starts_with(from)
&& let Ok(relative) = path.strip_prefix(from)
{
favorites_changed = true;
return Favorite::Named {
path: to.join(relative),
name: name.clone(),
};
}
}
}
_ => {}
} }
favorite.clone() favorite.clone()
}) })
@ -2613,6 +2630,11 @@ impl Application for App {
} }
items.push(cosmic::widget::menu::Item::Divider); items.push(cosmic::widget::menu::Item::Divider);
if favorite_index_opt.is_some() { if favorite_index_opt.is_some() {
items.push(cosmic::widget::menu::Item::Button(
fl!("change-sidebar-label"),
None,
NavMenuAction::ChangeSidebarLabel(entity),
));
items.push(cosmic::widget::menu::Item::Button( items.push(cosmic::widget::menu::Item::Button(
fl!("remove-from-sidebar"), fl!("remove-from-sidebar"),
None, None,
@ -2904,7 +2926,8 @@ impl Application for App {
} else { } else {
Favorite::from_path(path) Favorite::from_path(path)
}; };
if !favorites.contains(&favorite) { let favorite_path = favorite.path_opt();
if !favorites.iter().any(|f| f.path_opt() == favorite_path) {
favorites.push(favorite); favorites.push(favorite);
} }
} }
@ -3262,6 +3285,18 @@ impl Application for App {
DialogPage::DeleteTrash { items } => { DialogPage::DeleteTrash { items } => {
tasks.push(self.operation(Operation::DeleteTrash { items })); tasks.push(self.operation(Operation::DeleteTrash { items }));
} }
DialogPage::ChangeSidebarLabel { entity, label } => {
if let Some(FavoriteIndex(favorite_i)) =
self.nav_model.data::<FavoriteIndex>(entity)
{
let mut favorites = self.config.favorites.clone();
if let Some(favorite) = favorites.get_mut(*favorite_i) {
*favorite = favorite.with_label(label.trim());
config_set!(favorites, favorites);
tasks.push(self.update_config());
}
}
}
DialogPage::RenameItem { DialogPage::RenameItem {
from, parent, name, .. from, parent, name, ..
} => { } => {
@ -4487,7 +4522,8 @@ impl Application for App {
tab::Command::AddToSidebar(path) => { tab::Command::AddToSidebar(path) => {
let mut favorites = self.config.favorites.clone(); let mut favorites = self.config.favorites.clone();
let favorite = Favorite::from_path(path); let favorite = Favorite::from_path(path);
if !favorites.contains(&favorite) { let favorite_path = favorite.path_opt();
if !favorites.iter().any(|f| f.path_opt() == favorite_path) {
favorites.push(favorite); favorites.push(favorite);
} }
config_set!(favorites, favorites); config_set!(favorites, favorites);
@ -5271,6 +5307,26 @@ impl Application for App {
return self.update_config(); return self.update_config();
} }
} }
NavMenuAction::ChangeSidebarLabel(entity) => {
if let Some(favorite) = self
.nav_model
.data::<FavoriteIndex>(entity)
.and_then(|FavoriteIndex(favorite_i)| {
self.config.favorites.get(*favorite_i)
})
{
let label = favorite
.display_name()
.unwrap_or_else(|| fl!("filesystem"));
return Task::batch([
self.dialog_pages
.push_back(DialogPage::ChangeSidebarLabel { entity, label }),
widget::text_input::focus(self.dialog_text_input.clone()),
widget::text_input::select_all(self.dialog_text_input.clone()),
]);
}
}
}, },
Message::Recents => { Message::Recents => {
if self.config.show_recents { if self.config.show_recents {
@ -6164,6 +6220,40 @@ impl Application for App {
target = target target = target
))) )))
} }
DialogPage::ChangeSidebarLabel { entity, label } => {
let entity = *entity;
let complete_maybe = if label.trim().is_empty() {
None
} else {
Some(Message::DialogComplete)
};
widget::dialog()
.title(fl!("change-sidebar-label"))
.primary_action(
widget::button::suggested(fl!("save"))
.on_press_maybe(complete_maybe.clone()),
)
.secondary_action(
widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
)
.control(
widget::column::with_children([
widget::text::body(fl!("sidebar-label")).into(),
widget::text_input("", label.as_str())
.id(self.dialog_text_input.clone())
.on_input(move |label| {
Message::DialogUpdate(DialogPage::ChangeSidebarLabel {
entity,
label,
})
})
.on_submit_maybe(complete_maybe.map(|maybe| move |_| maybe.clone()))
.into(),
])
.spacing(space_xxs),
)
}
DialogPage::RenameItem { DialogPage::RenameItem {
from, from,
parent, parent,

View file

@ -70,6 +70,11 @@ pub enum Favorite {
name: String, name: String,
path: PathBuf, path: PathBuf,
}, },
/// A path with a custom name chosen by the user
Named {
path: PathBuf,
name: String,
},
} }
impl Favorite { impl Favorite {
@ -98,6 +103,38 @@ impl Favorite {
Self::Videos => dirs::video_dir(), Self::Videos => dirs::video_dir(),
Self::Path(path) => Some(path.clone()), Self::Path(path) => Some(path.clone()),
Self::Network { path, .. } => Some(path.clone()), Self::Network { path, .. } => Some(path.clone()),
Self::Named { path, .. } => Some(path.clone()),
}
}
/// Name shown in the sidebar, or `None` if the path has no usable file name
pub fn display_name(&self) -> Option<String> {
match self {
Self::Home => Some(crate::fl!("home")),
Self::Named { name, .. } | Self::Network { name, .. } => Some(name.clone()),
_ => self
.path_opt()?
.file_name()
.and_then(|x| x.to_str())
.map(ToString::to_string),
}
}
/// Return this favorite with a custom sidebar label chosen by the user
pub fn with_label(&self, name: &str) -> Self {
match self {
Self::Network { uri, path, .. } => Self::Network {
uri: uri.clone(),
name: name.to_string(),
path: path.clone(),
},
other => match other.path_opt() {
Some(path) => Self::Named {
path,
name: name.to_string(),
},
None => other.clone(),
},
} }
} }
} }
@ -385,3 +422,66 @@ pub const TIME_CONFIG_ID: &str = "com.system76.CosmicAppletTime";
pub struct TimeConfig { pub struct TimeConfig {
pub military_time: bool, pub military_time: bool,
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn favorite_with_label_converts_path_to_named() {
let favorite = Favorite::Path(PathBuf::from("/some/dir"));
assert_eq!(
favorite.with_label("Custom"),
Favorite::Named {
path: PathBuf::from("/some/dir"),
name: "Custom".to_string(),
}
);
}
#[test]
fn favorite_with_label_updates_network_in_place() {
let favorite = Favorite::Network {
uri: "sftp://example.com/".to_string(),
name: "example.com".to_string(),
path: PathBuf::from("/run/mount/example"),
};
assert_eq!(
favorite.with_label("Custom"),
Favorite::Network {
uri: "sftp://example.com/".to_string(),
name: "Custom".to_string(),
path: PathBuf::from("/run/mount/example"),
}
);
}
#[test]
fn favorite_with_label_converts_special_folder_to_named() {
let home = dirs::home_dir().unwrap();
assert_eq!(
Favorite::Home.with_label("Custom"),
Favorite::Named {
path: home,
name: "Custom".to_string(),
}
);
}
#[test]
fn favorite_display_name() {
assert_eq!(
Favorite::Path(PathBuf::from("/some/dir")).display_name(),
Some("dir".to_string())
);
assert_eq!(Favorite::Path(PathBuf::from("/")).display_name(), None);
assert_eq!(
Favorite::Named {
path: PathBuf::from("/some/dir"),
name: "Custom".to_string(),
}
.display_name(),
Some("Custom".to_string())
);
}
}

View file

@ -32,9 +32,7 @@ use std::{env, fmt, fs};
use crate::app::{ use crate::app::{
Action, ContextPage, Message as AppMessage, PreviewItem, PreviewKind, REPLACE_BUTTON_ID, Action, ContextPage, Message as AppMessage, PreviewItem, PreviewKind, REPLACE_BUTTON_ID,
}; };
use crate::config::{ use crate::config::{Config, DialogConfig, TIME_CONFIG_ID, ThumbCfg, TimeConfig, TypeToSearch};
Config, DialogConfig, Favorite, TIME_CONFIG_ID, ThumbCfg, TimeConfig, TypeToSearch,
};
use crate::key_bind::key_binds; use crate::key_bind::key_binds;
use crate::localize::LANGUAGE_SORTER; use crate::localize::LANGUAGE_SORTER;
use crate::mounter::{MOUNTERS, MounterItem, MounterItems, MounterKey, MounterMessage}; use crate::mounter::{MOUNTERS, MounterItem, MounterItems, MounterKey, MounterMessage};
@ -889,13 +887,7 @@ impl App {
for favorite in &self.flags.config.favorites { for favorite in &self.flags.config.favorites {
if let Some(path) = favorite.path_opt() { if let Some(path) = favorite.path_opt() {
let name = if matches!(favorite, Favorite::Home) { let Some(name) = favorite.display_name() else {
fl!("home")
} else if let Favorite::Network { name, .. } = favorite {
name.clone()
} else if let Some(file_name) = path.file_name().and_then(|x| x.to_str()) {
file_name.to_string()
} else {
continue; continue;
}; };
nav_model = nav_model.insert(move |b| { nav_model = nav_model.insert(move |b| {