diff --git a/README.md b/README.md index b7656a9..630078b 100644 --- a/README.md +++ b/README.md @@ -42,17 +42,19 @@ This project is currently in alpha stage. Progress and next steps: - AirDrop: Shows as "AirDrop" without exposing internal URL - Recents: Shows as "Recents" without exposing internal URL - Applications: Shows as "Applications" without exposing internal URL + - Desktop: Shows as "~/Desktop" for user's desktop folder + - Downloads: Shows as "~/Downloads" for user's downloads folder +- User-friendly path formatting (show regular paths instead of raw URLs) 🚧 **In Progress**: -- User-friendly path formatting (show regular paths instead of raw URLs) +- Support for custom folder locations 🔜 **Planned**: -- Handle special locations: - - User Desktop (`file:///Users//Desktop/`) - - User Downloads (`file:///Users//Downloads/`) - Add/remove favorites - Command-line interface improvements +- Configuration options + ## Documentation - [Architecture Overview](docs/architecture.md) diff --git a/src/finder/sidebar.rs b/src/finder/sidebar.rs index 0da9eef..0f9cd39 100644 --- a/src/finder/sidebar.rs +++ b/src/finder/sidebar.rs @@ -5,10 +5,18 @@ pub enum Target { AirDrop, Recents, Applications, - Downloads, Custom { label: String, path: String }, } +impl Target { + pub fn custom(label: impl Into, path: impl Into) -> Self { + Self::Custom { + label: label.into(), + path: path.into(), + } + } +} + #[derive(Debug, PartialEq)] pub struct SidebarItem { target: Target, @@ -26,7 +34,6 @@ impl fmt::Display for SidebarItem { Target::AirDrop => write!(f, "AirDrop"), Target::Recents => write!(f, "Recents"), Target::Applications => write!(f, "Applications"), - Target::Downloads => write!(f, "~/Downloads"), Target::Custom { label, path } => write!(f, "{} -> {}", label, path), } } @@ -40,11 +47,8 @@ mod tests { #[test] fn should_create_sidebar_item_with_custom_target() { - let item = SidebarItem::new(Target::Custom { - label: "Documents".to_string(), - path: "/Users/user/Documents".to_string(), - }); - assert_eq!(format!("{}", item), "Documents -> /Users/user/Documents"); + let item = SidebarItem::new(Target::custom("Projects", "/Users/user/Projects")); + assert_eq!(format!("{}", item), "Projects -> /Users/user/Projects"); } #[test] @@ -64,10 +68,4 @@ mod tests { let item = SidebarItem::new(Target::Applications); assert_eq!(format!("{}", item), "Applications"); } - - #[test] - fn should_create_sidebar_item_with_downloads() { - let item = SidebarItem::new(Target::Downloads); - assert_eq!(format!("{}", item), "~/Downloads"); - } } diff --git a/src/system/favorites/item.rs b/src/system/favorites/item.rs new file mode 100644 index 0000000..7a9ee10 --- /dev/null +++ b/src/system/favorites/item.rs @@ -0,0 +1,145 @@ +use std::fmt; + +use crate::{ + finder::Target, + system::favorites::{DisplayName, Url}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MacOsUrl { + AirDrop, + Recents, + Applications, + Custom(String), +} + +impl MacOsUrl { + const AIRDROP: &'static str = "nwnode://domain-AirDrop"; + const RECENTS: &'static str = "file:///System/Library/CoreServices/Finder.app/Contents/Resources/MyLibraries/myDocuments.cannedSearch/"; + const APPLICATIONS: &'static str = "file:///Applications/"; + + fn clean_path(url: impl AsRef) -> String { + url.as_ref() + .strip_prefix("file://") + .and_then(|p| p.strip_suffix('/')) + .unwrap_or(url.as_ref()) + .to_string() + } +} + +impl From for MacOsUrl { + fn from(url: Url) -> Self { + let url_str = url.to_string(); + + match url_str.as_str() { + Self::AIRDROP => Self::AirDrop, + Self::RECENTS => Self::Recents, + Self::APPLICATIONS => Self::Applications, + url => Self::Custom(Self::clean_path(url)), + } + } +} + +#[derive(Debug, Clone)] +pub struct FavoriteItem { + url: Url, + name: DisplayName, +} + +impl FavoriteItem { + pub fn new(url: Url, name: DisplayName) -> Self { + Self { url, name } + } +} + +impl fmt::Display for FavoriteItem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} -> {}", self.name, self.url) + } +} + +impl From for Target { + fn from(item: FavoriteItem) -> Self { + match MacOsUrl::from(item.url) { + MacOsUrl::AirDrop => Target::AirDrop, + MacOsUrl::Recents => Target::Recents, + MacOsUrl::Applications => Target::Applications, + MacOsUrl::Custom(path) => Target::custom(item.name.to_string(), path), + } + } +} + +#[cfg(test)] +mod tests { + use core_foundation::{ + base::TCFType, + string::CFString, + url::{CFURL, kCFURLPOSIXPathStyle}, + }; + use pretty_assertions::assert_eq; + + use super::*; + + fn create_url(path: &str) -> Url { + let cf_string = CFString::new(path); + let is_dir = path.ends_with('/'); + let cf_url = CFURL::from_file_system_path(cf_string, kCFURLPOSIXPathStyle, is_dir); + Url::try_from(cf_url.as_concrete_TypeRef()).unwrap() + } + + fn create_display_name(name: &str) -> DisplayName { + let cf_string = CFString::new(name); + DisplayName::try_from(cf_string.as_concrete_TypeRef()).unwrap() + } + + #[test] + fn should_convert_airdrop_url() { + let target = Target::from(FavoriteItem::new( + create_url(MacOsUrl::AIRDROP), + create_display_name("AirDrop"), + )); + assert_eq!(target, Target::AirDrop); + } + + #[test] + fn should_convert_recents_url() { + let target = Target::from(FavoriteItem::new( + create_url(MacOsUrl::RECENTS), + create_display_name("Recents"), + )); + assert_eq!(target, Target::Recents); + } + + #[test] + fn should_convert_applications_url() { + let target = Target::from(FavoriteItem::new( + create_url(MacOsUrl::APPLICATIONS), + create_display_name("Applications"), + )); + assert_eq!(target, Target::Applications); + } + + #[test] + fn should_convert_custom_url() { + let target = Target::from(FavoriteItem::new( + create_url("file:///Users/user/Projects/"), + create_display_name("Projects"), + )); + assert_eq!(target, Target::Custom { + label: "Projects".to_string(), + path: "/Users/user/Projects".to_string(), + }); + } + + #[test] + fn should_format_favorite_item() { + let item = FavoriteItem::new( + create_url("file:///Users/user/Projects/"), + create_display_name("Projects"), + ); + assert_eq!( + format!("{}", item), + "Projects -> file:///Users/user/Projects/" + ); + } +} diff --git a/src/system/favorites/mod.rs b/src/system/favorites/mod.rs index 1d8b654..b2ef05d 100644 --- a/src/system/favorites/mod.rs +++ b/src/system/favorites/mod.rs @@ -1,20 +1,20 @@ mod display_name; mod errors; mod handle; +mod item; mod snapshot; mod snapshot_item; mod url; -mod url_mapper; use core_foundation::base::kCFAllocatorDefault; use core_services::{LSSharedFileListResolutionFlags, kLSSharedFileListFavoriteItems}; pub use display_name::DisplayName; pub use errors::FavoritesError; pub use handle::FavoritesHandle; +pub use item::FavoriteItem; pub use snapshot::Snapshot; pub use snapshot_item::SnapshotItem; pub use url::Url; -pub use url_mapper::TargetUrl; use crate::{ finder::{Result, SidebarItem, Target, favorites::FavoritesApi}, @@ -72,7 +72,7 @@ impl Favorites { unsafe fn convert_item(&self, item: SnapshotItem) -> Result { let url = unsafe { self.copy_resolved_url(&item) }?; let name = unsafe { self.copy_display_name(&item) }?; - let target = Target::from(TargetUrl(url, name)); + let target = Target::from(FavoriteItem::new(url, name)); Ok(SidebarItem::new(target)) } } diff --git a/src/system/favorites/url_mapper.rs b/src/system/favorites/url_mapper.rs deleted file mode 100644 index dea1df6..0000000 --- a/src/system/favorites/url_mapper.rs +++ /dev/null @@ -1,106 +0,0 @@ -use crate::{ - finder::Target, - system::favorites::{DisplayName, Url}, -}; - -pub struct TargetUrl(pub Url, pub DisplayName); - -const AIRDROP_URL: &str = "nwnode://domain-AirDrop"; -const RECENTS_URL: &str = "file:///System/Library/CoreServices/Finder.app/Contents/Resources/MyLibraries/myDocuments.cannedSearch/"; -const APPLICATIONS_URL: &str = "file:///Applications/"; - -fn is_downloads_url(url: &str) -> bool { - let url_path = url.strip_prefix("file://").unwrap_or(url); - url_path.matches('/').count() == 4 - && url_path.ends_with("/Downloads/") - && url_path.starts_with("/Users/") -} - -impl From for Target { - fn from(target: TargetUrl) -> Self { - let url = target.0.to_string(); - - match url.as_str() { - AIRDROP_URL => Target::AirDrop, - RECENTS_URL => Target::Recents, - APPLICATIONS_URL => Target::Applications, - path if is_downloads_url(path) => Target::Downloads, - path => Target::Custom { - label: target.1.to_string(), - path: path.to_string(), - }, - } - } -} - -#[cfg(test)] -mod tests { - use core_foundation::{ - base::TCFType, - string::CFString, - url::{CFURL, kCFURLPOSIXPathStyle}, - }; - use pretty_assertions::assert_eq; - - use super::*; - - fn create_url(path: &str) -> Url { - let cf_string = CFString::new(path); - let is_dir = path.ends_with('/'); - let cf_url = CFURL::from_file_system_path(cf_string, kCFURLPOSIXPathStyle, is_dir); - Url::try_from(cf_url.as_concrete_TypeRef()).unwrap() - } - - fn create_display_name(name: &str) -> DisplayName { - let cf_string = CFString::new(name); - DisplayName::try_from(cf_string.as_concrete_TypeRef()).unwrap() - } - - #[test] - fn should_convert_airdrop_url() { - let target = Target::from(TargetUrl( - create_url(AIRDROP_URL), - create_display_name("AirDrop"), - )); - assert_eq!(target, Target::AirDrop); - } - - #[test] - fn should_convert_recents_url() { - let target = Target::from(TargetUrl( - create_url(RECENTS_URL), - create_display_name("Recents"), - )); - assert_eq!(target, Target::Recents); - } - - #[test] - fn should_convert_applications_url() { - let target = Target::from(TargetUrl( - create_url(APPLICATIONS_URL), - create_display_name("Applications"), - )); - assert_eq!(target, Target::Applications); - } - - #[test] - fn should_convert_downloads_url() { - let target = Target::from(TargetUrl( - create_url("file:///Users/user/Downloads/"), - create_display_name("Downloads"), - )); - assert_eq!(target, Target::Downloads); - } - - #[test] - fn should_convert_custom_url() { - let target = Target::from(TargetUrl( - create_url("file:///Users/user/Documents/"), - create_display_name("Documents"), - )); - assert_eq!(target, Target::Custom { - label: "Documents".to_string(), - path: "file:///Users/user/Documents/".to_string(), - }); - } -} diff --git a/tests/finder.rs b/tests/finder.rs index 0d00f92..40264d5 100644 --- a/tests/finder.rs +++ b/tests/finder.rs @@ -8,18 +8,21 @@ mod mock; use mock::{favorites::FavoritesBuilder, mac_os_api::MockMacOsApiBuilder}; mod constants { - // Display Labels - pub const RECENTS_LABEL: &str = "Recents"; - pub const APPLICATIONS_LABEL: &str = "Applications"; - pub const DOWNLOADS_LABEL: &str = "Downloads"; - pub const DOCUMENTS_LABEL: &str = "Documents"; - - // URLs + // AirDrop pub const AIRDROP_URL: &str = "nwnode://domain-AirDrop"; + + // Recents + pub const RECENTS_LABEL: &str = "Recents"; pub const RECENTS_URL: &str = "file:///System/Library/CoreServices/Finder.app/Contents/Resources/MyLibraries/myDocuments.cannedSearch/"; + + // Applications + pub const APPLICATIONS_LABEL: &str = "Applications"; pub const APPLICATIONS_URL: &str = "file:///Applications/"; - pub const DOWNLOADS_URL: &str = "file:///Users/user/Downloads/"; - pub const DOCUMENTS_URL: &str = "file:///Users/user/Documents/"; + + // Projects + pub const PROJECTS_LABEL: &str = "Projects"; + pub const PROJECTS_PATH: &str = "/Users/user/Projects"; + pub const PROJECTS_URL: &str = "file:///Users/user/Projects/"; } #[test] @@ -67,27 +70,6 @@ fn should_return_empty_list_when_no_favorites() -> Result<()> { Ok(()) } -#[test] -fn should_return_favorite_with_display_name_and_url() -> Result<()> { - // Arrange - let expected_result = vec![SidebarItem::new(Target::Custom { - label: constants::DOCUMENTS_LABEL.to_string(), - path: constants::DOCUMENTS_URL.to_string(), - })]; - let favorites = FavoritesBuilder::new() - .add_item(Some(constants::DOCUMENTS_LABEL), constants::DOCUMENTS_URL) - .build(); - let mock_api = MockMacOsApiBuilder::new().with_favorites(favorites).build(); - let finder = Finder::new(mock_api); - - // Act - let result = finder.get_favorites_list()?; - - // Assert - assert_eq!(result, expected_result); - Ok(()) -} - #[test] fn should_handle_airdrop_item() -> Result<()> { // Arrange @@ -146,11 +128,23 @@ fn should_handle_applications_item() -> Result<()> { } #[test] -fn should_handle_downloads_item() -> Result<()> { +fn should_handle_multiple_favorites() -> Result<()> { // Arrange - let expected_result = vec![SidebarItem::new(Target::Downloads)]; + let expected_result = vec![ + SidebarItem::new(Target::AirDrop), + SidebarItem::new(Target::Applications), + SidebarItem::new(Target::Custom { + label: constants::PROJECTS_LABEL.to_string(), + path: constants::PROJECTS_PATH.to_string(), + }), + ]; let favorites = FavoritesBuilder::new() - .add_item(Some(constants::DOWNLOADS_LABEL), constants::DOWNLOADS_URL) + .add_item(None, constants::AIRDROP_URL) + .add_item( + Some(constants::APPLICATIONS_LABEL), + constants::APPLICATIONS_URL, + ) + .add_item(Some(constants::PROJECTS_LABEL), constants::PROJECTS_URL) .build(); let mock_api = MockMacOsApiBuilder::new().with_favorites(favorites).build(); let finder = Finder::new(mock_api); @@ -164,20 +158,15 @@ fn should_handle_downloads_item() -> Result<()> { } #[test] -fn should_handle_multiple_favorites() -> Result<()> { +fn should_handle_custom_location() -> Result<()> { // Arrange - let expected_result = vec![ - SidebarItem::new(Target::AirDrop), - SidebarItem::new(Target::Applications), - SidebarItem::new(Target::Downloads), - ]; + let expected_result = vec![SidebarItem::new(Target::Custom { + label: constants::PROJECTS_LABEL.to_string(), + path: constants::PROJECTS_PATH.to_string(), + })]; + let favorites = FavoritesBuilder::new() - .add_item(None, constants::AIRDROP_URL) - .add_item( - Some(constants::APPLICATIONS_LABEL), - constants::APPLICATIONS_URL, - ) - .add_item(Some(constants::DOWNLOADS_LABEL), constants::DOWNLOADS_URL) + .add_item(Some(constants::PROJECTS_LABEL), constants::PROJECTS_URL) .build(); let mock_api = MockMacOsApiBuilder::new().with_favorites(favorites).build(); let finder = Finder::new(mock_api);