diff --git a/src/config.rs b/src/config.rs index 0471dd2..1775522 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,7 @@ pub struct AppConfig { pub cache_quota_gb: u64, pub fast_image_loading: bool, pub locations: Vec, + pub favorites: Vec, } impl Default for AppConfig { @@ -19,6 +20,7 @@ impl Default for AppConfig { cache_quota_gb: 10, fast_image_loading: true, locations: Vec::new(), + favorites: Vec::new(), } } } @@ -36,6 +38,14 @@ pub struct LocationProfile { pub domain: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FavoriteEntry { + pub name: String, + pub uri: String, + pub is_directory: bool, + pub profile: LocationProfile, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LocationKind { @@ -137,16 +147,32 @@ mod tests { password: "plain-password".into(), domain: String::new(), }], + favorites: vec![FavoriteEntry { + name: "원격 만화".into(), + uri: "sftp://example.invalid/comics/book.zip".into(), + is_directory: false, + profile: LocationProfile { + name: "즐겨찾기 서버".into(), + kind: LocationKind::Sftp, + address: "sftp://example.invalid/comics".into(), + username: "favorite-reader".into(), + password: "favorite-password".into(), + domain: String::new(), + }, + }], }; let encoded = toml::to_string(&config).unwrap(); let decoded: AppConfig = toml::from_str(&encoded).unwrap(); assert_eq!(decoded.locations[0].password, "plain-password"); + assert_eq!(decoded.favorites[0].profile.username, "favorite-reader"); + assert_eq!(decoded.favorites[0].profile.password, "favorite-password"); } #[test] fn fast_image_loading_is_enabled_for_existing_configs() { let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); assert!(decoded.fast_image_loading); + assert!(decoded.favorites.is_empty()); } } diff --git a/src/source.rs b/src/source.rs index cc07e17..7dd535e 100644 --- a/src/source.rs +++ b/src/source.rs @@ -177,6 +177,26 @@ pub fn mount_operation( operation } +pub async fn ensure_mounted( + file: &gio::File, + profile: &LocationProfile, + operation: >k::MountOperation, +) -> Result<()> { + if profile.kind == LocationKind::Local || file.is_native() { + return Ok(()); + } + match file + .mount_enclosing_volume_future(gio::MountMountFlags::NONE, Some(operation)) + .await + { + Ok(()) => Ok(()), + Err(error) if error.matches(gio::IOErrorEnum::AlreadyMounted) => Ok(()), + Err(error) => { + Err(error).with_context(|| format!("원격 위치를 마운트할 수 없습니다: {}", file.uri())) + } + } +} + async fn enumerate_with_mount_retry( directory: &gio::File, operation: >k::MountOperation, diff --git a/src/viewer.rs b/src/viewer.rs index f1ca10c..9e85b6a 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -23,6 +23,11 @@ pub struct ViewerWindow; pub type SiblingNavigator = Rc bool>; +pub struct FavoriteControl { + pub active: bool, + pub toggle: Rc bool>, +} + impl ViewerWindow { pub fn open( application: >k::Application, @@ -30,6 +35,7 @@ impl ViewerWindow { database: Rc, fast_image_loading: bool, sibling_navigator: Option, + favorite_control: FavoriteControl, ) { let title = source.display_name.clone(); let window = gtk::ApplicationWindow::builder() @@ -40,6 +46,8 @@ impl ViewerWindow { .build(); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); + let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); + header.pack_end(&favorite_button); window.set_titlebar(Some(&header)); let root = gtk::Box::new(gtk::Orientation::Vertical, 0); @@ -152,6 +160,8 @@ impl ViewerWindow { direction_dropdown, fast_image_loading, sibling_navigator, + favorite_button, + favorite_toggle: favorite_control.toggle, state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -228,6 +238,8 @@ struct ViewerController { direction_dropdown: gtk::DropDown, fast_image_loading: bool, sibling_navigator: Option, + favorite_button: gtk::Button, + favorite_toggle: Rc bool>, state: RefCell, } @@ -252,6 +264,11 @@ impl ViewerController { self.direction_dropdown .connect_selected_notify(move |_| controller.render()); let controller = Rc::clone(self); + self.favorite_button.connect_clicked(move |_| { + let active = (controller.favorite_toggle)(); + controller.favorite_button.set_label(favorite_label(active)); + }); + let controller = Rc::clone(self); self.page_seek.connect_value_changed(move |scale| { if let Some(index) = page_seek_index(scale.value(), controller.state.borrow().pages.len()) @@ -927,6 +944,7 @@ impl MediaViewerWindow { source: ArchiveSource, kind: EntryKind, sibling_navigator: Option, + favorite_control: FavoriteControl, ) { let title = source.display_name.clone(); let window = gtk::ApplicationWindow::builder() @@ -937,6 +955,8 @@ impl MediaViewerWindow { .build(); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); + let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); + header.pack_end(&favorite_button); window.set_titlebar(Some(&header)); let root = gtk::Box::new(gtk::Orientation::Vertical, 0); @@ -974,6 +994,8 @@ impl MediaViewerWindow { stack, status_label, sibling_navigator, + favorite_button, + favorite_toggle: favorite_control.toggle, }); if let Some(stream) = controller.video.media_stream() { let status_label = controller.status_label.clone(); @@ -1006,6 +1028,8 @@ struct MediaViewerController { stack: gtk::Stack, status_label: gtk::Label, sibling_navigator: Option, + favorite_button: gtk::Button, + favorite_toggle: Rc bool>, } impl MediaViewerController { @@ -1032,6 +1056,11 @@ impl MediaViewerController { _ => glib::Propagation::Proceed, }); self.window.add_controller(keys); + let controller = Rc::clone(self); + self.favorite_button.connect_clicked(move |_| { + let active = (controller.favorite_toggle)(); + controller.favorite_button.set_label(favorite_label(active)); + }); } fn open_sibling(&self, forward: bool) { @@ -1098,6 +1127,14 @@ impl MediaViewerController { } } +fn favorite_label(active: bool) -> &'static str { + if active { + "즐겨찾기 제거" + } else { + "즐겨찾기 추가" + } +} + fn page_picture() -> gtk::Picture { let picture = gtk::Picture::new(); picture.set_content_fit(gtk::ContentFit::Contain); diff --git a/src/window.rs b/src/window.rs index 042803f..95c4229 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,6 +1,6 @@ use std::cell::RefCell; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::rc::Rc; use std::time::{Duration, Instant}; @@ -11,14 +11,16 @@ use gtk::gio::prelude::*; use gtk::glib; use gtk::prelude::*; -use crate::browser::{BrowserEntry, EntryKind, SortDirection, SortField, sort_entries}; -use crate::config::{AppConfig, LocationKind, LocationProfile}; +use crate::browser::{ + BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries, +}; +use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile}; use crate::database::Database; use crate::source::{ - display_remote_uri, enumerate_directory, file_from_user_input, is_within_root, mount_operation, - parent_within_root, root_file, validate_profile, + display_remote_uri, ensure_mounted, enumerate_directory, file_from_user_input, is_within_root, + mount_operation, parent_within_root, root_file, validate_profile, }; -use crate::viewer::{MediaViewerWindow, SiblingNavigator, ViewerWindow}; +use crate::viewer::{FavoriteControl, MediaViewerWindow, SiblingNavigator, ViewerWindow}; use crate::zip_cache::ZipCache; pub struct ComicWindow { @@ -66,6 +68,12 @@ impl ComicWindow { locations.append(&edit_location_button); locations.append(&delete_location_button); + let favorites = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let favorite_dropdown = gtk::DropDown::from_strings(&["즐겨찾기 선택"]); + favorite_dropdown.set_hexpand(true); + favorites.append(>k::Label::new(Some("즐겨찾기"))); + favorites.append(&favorite_dropdown); + let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6); let up_button = gtk::Button::builder() .icon_name("go-up-symbolic") @@ -109,6 +117,7 @@ impl ComicWindow { let status_label = gtk::Label::builder().xalign(0.0).build(); root.append(&locations); + root.append(&favorites); root.append(&navigation); root.append(&controls); root.append(&scrolled); @@ -118,6 +127,7 @@ impl ComicWindow { let controller = Rc::new(BrowserController { window, location_dropdown, + favorite_dropdown, edit_location_button, delete_location_button, path_entry, @@ -128,6 +138,7 @@ impl ComicWindow { status_label, state: RefCell::new(BrowserState { profile_index: 0, + favorite_profile: None, root_uri: root_uri.clone(), current_uri: root_uri, entries: Vec::new(), @@ -145,6 +156,7 @@ impl ComicWindow { }); controller.rebuild_profiles(); + controller.rebuild_favorites(); controller.connect_signals( open_button, refresh_button, @@ -163,6 +175,7 @@ impl ComicWindow { struct BrowserState { profile_index: usize, + favorite_profile: Option, root_uri: String, current_uri: String, entries: Vec, @@ -177,6 +190,7 @@ struct BrowserState { struct BrowserController { window: gtk::ApplicationWindow, location_dropdown: gtk::DropDown, + favorite_dropdown: gtk::DropDown, edit_location_button: gtk::Button, delete_location_button: gtk::Button, path_entry: gtk::Entry, @@ -228,6 +242,9 @@ impl BrowserController { self.location_dropdown .connect_selected_notify(move |_| controller.select_profile()); let controller = Rc::clone(self); + self.favorite_dropdown + .connect_selected_notify(move |_| controller.select_favorite()); + let controller = Rc::clone(self); self.search_entry .connect_search_changed(move |_| controller.render()); let controller = Rc::clone(self); @@ -265,6 +282,16 @@ impl BrowserController { profiles } + fn current_profile(&self) -> LocationProfile { + let state = self.state.borrow(); + state.favorite_profile.clone().unwrap_or_else(|| { + self.profiles() + .get(state.profile_index) + .cloned() + .unwrap_or_else(home_profile) + }) + } + fn rebuild_profiles(&self) { let profiles = self.profiles(); let names: Vec<_> = profiles @@ -275,6 +302,24 @@ impl BrowserController { .set_model(Some(>k::StringList::new(&names))); } + fn rebuild_favorites(&self) { + let config = self.config.borrow(); + let mut names = Vec::with_capacity(config.favorites.len() + 1); + names.push("즐겨찾기 선택".to_owned()); + names.extend(config.favorites.iter().map(|favorite| { + let marker = if favorite.is_directory { + "폴더" + } else { + "파일" + }; + format!("[{marker}] {}", favorite.name) + })); + let names: Vec<_> = names.iter().map(String::as_str).collect(); + self.favorite_dropdown + .set_model(Some(>k::StringList::new(&names))); + self.favorite_dropdown.set_selected(0); + } + fn select_profile(self: &Rc) { let profile_index = self.location_dropdown.selected() as usize; let editable = profile_index > 0 && profile_index <= self.config.borrow().locations.len(); @@ -289,6 +334,7 @@ impl BrowserController { let root_uri: String = root.uri().into(); let mut state = self.state.borrow_mut(); state.profile_index = profile_index; + state.favorite_profile = None; state.root_uri = root_uri.clone(); state.current_uri = root_uri; drop(state); @@ -299,8 +345,77 @@ impl BrowserController { } } + fn select_favorite(self: &Rc) { + let Some(index) = (self.favorite_dropdown.selected() as usize).checked_sub(1) else { + return; + }; + let Some(favorite) = self.config.borrow().favorites.get(index).cloned() else { + self.favorite_dropdown.set_selected(0); + return; + }; + self.favorite_dropdown.set_selected(0); + let Ok(root) = root_file(&favorite.profile) else { + self.status_label + .set_text("즐겨찾기의 저장된 위치가 올바르지 않습니다."); + return; + }; + let favorite_file = gio::File::for_uri(&favorite.uri); + if !is_within_root(&favorite_file, &root) { + self.status_label + .set_text("즐겨찾기가 저장된 위치의 루트 밖을 가리킵니다."); + return; + } + if favorite.is_directory { + self.location_dropdown + .set_selected(gtk::INVALID_LIST_POSITION); + self.edit_location_button.set_sensitive(false); + self.delete_location_button.set_sensitive(false); + let mut state = self.state.borrow_mut(); + state.favorite_profile = Some(favorite.profile.clone()); + state.root_uri = root.uri().into(); + state.current_uri = favorite.uri; + drop(state); + self.search_entry.set_text(""); + self.refresh(); + return; + } + + self.status_label.set_text("즐겨찾기에 연결하는 중..."); + let operation = mount_operation(&favorite.profile, &self.window); + let controller = Rc::downgrade(self); + glib::MainContext::default().spawn_local(async move { + let file = gio::File::for_uri(&favorite.uri); + let result = ensure_mounted(&file, &favorite.profile, &operation).await; + let Some(controller) = controller.upgrade() else { + return; + }; + if let Err(error) = result { + controller.status_label.set_text(&format!("{error:#}")); + return; + } + let kind = classify_file(Path::new(&favorite.name)); + if !kind.is_viewable() { + controller + .status_label + .set_text("이 즐겨찾기 파일 형식은 열 수 없습니다."); + return; + } + let entry = BrowserEntry { + uri: favorite.uri, + name: favorite.name, + kind, + size: None, + modified: None, + created: None, + etag: None, + }; + let siblings = Rc::new(vec![entry.clone()]); + controller.open_viewable(entry, siblings, 0, favorite.profile); + }); + } + fn open_entered_path(self: &Rc) { - let profile = self.profiles()[self.state.borrow().profile_index].clone(); + let profile = self.current_profile(); let entered = self.path_entry.text(); let file = file_from_user_input(profile.kind, entered.as_str()); let root = gio::File::for_uri(&self.state.borrow().root_uri); @@ -331,7 +446,10 @@ impl BrowserController { state.typeahead_query.clear(); state.typeahead_last_input = None; ( - self.profiles()[state.profile_index].clone(), + state + .favorite_profile + .clone() + .unwrap_or_else(|| self.profiles()[state.profile_index].clone()), state.current_uri.clone(), state.generation, ) @@ -502,7 +620,7 @@ impl BrowserController { }; let siblings = Rc::new(siblings); if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) { - self.open_viewable(entry, siblings, current); + self.open_viewable(entry, siblings, current, self.current_profile()); } } EntryKind::OtherFile => {} @@ -514,10 +632,13 @@ impl BrowserController { entry: BrowserEntry, siblings: Rc>, current: usize, + profile: LocationProfile, ) { match entry.kind { - EntryKind::ZipArchive => self.open_archive(entry, siblings, current), - EntryKind::Image | EntryKind::Video => self.open_media(entry, siblings, current), + EntryKind::ZipArchive => self.open_archive(entry, siblings, current, profile), + EntryKind::Image | EntryKind::Video => { + self.open_media(entry, siblings, current, profile) + } _ => {} } } @@ -526,6 +647,7 @@ impl BrowserController { self: &Rc, siblings: Rc>, current: usize, + profile: LocationProfile, ) -> SiblingNavigator { let controller = Rc::downgrade(self); Rc::new(move |forward| { @@ -538,7 +660,12 @@ impl BrowserController { .set_text("더 이상 이동할 파일이 없습니다."); return false; }; - controller.open_viewable(siblings[next].clone(), Rc::clone(&siblings), next); + controller.open_viewable( + siblings[next].clone(), + Rc::clone(&siblings), + next, + profile.clone(), + ); true }) } @@ -548,11 +675,13 @@ impl BrowserController { entry: BrowserEntry, siblings: Rc>, current: usize, + profile: LocationProfile, ) { self.status_label.set_text("ZIP 파일 다운로드 준비 중..."); let progress_label = self.status_label.clone(); let controller = Rc::clone(self); - let navigator = self.sibling_navigator(siblings, current); + let navigator = self.sibling_navigator(siblings, current, profile.clone()); + let favorite_control = self.favorite_control(&entry, &profile); self.zip_cache.prepare( entry, move |current, total| { @@ -571,6 +700,7 @@ impl BrowserController { Rc::clone(&controller.database), controller.config.borrow().fast_image_loading, Some(navigator), + favorite_control, ); } Err(error) => controller.status_label.set_text(&format!("{error:#}")), @@ -583,13 +713,15 @@ impl BrowserController { entry: BrowserEntry, siblings: Rc>, current: usize, + profile: LocationProfile, ) { let kind = entry.kind; self.status_label .set_text("미디어 파일 다운로드 준비 중..."); let progress_label = self.status_label.clone(); let controller = Rc::clone(self); - let navigator = self.sibling_navigator(siblings, current); + let navigator = self.sibling_navigator(siblings, current, profile.clone()); + let favorite_control = self.favorite_control(&entry, &profile); self.zip_cache.prepare_media( entry, move |current, total| { @@ -602,7 +734,13 @@ impl BrowserController { let Some(application) = controller.window.application() else { return; }; - MediaViewerWindow::open(&application, source, kind, Some(navigator)); + MediaViewerWindow::open( + &application, + source, + kind, + Some(navigator), + favorite_control, + ); } Err(error) => controller.status_label.set_text(&format!("{error:#}")), }, @@ -622,27 +760,97 @@ impl BrowserController { else { return; }; - if entry.kind == EntryKind::Directory || gio::File::for_uri(&entry.uri).is_native() { + let can_favorite = entry.kind == EntryKind::Directory || entry.kind.is_viewable(); + let can_download = + entry.kind != EntryKind::Directory && !gio::File::for_uri(&entry.uri).is_native(); + if !can_favorite && !can_download { return; } self.list_box.select_row(Some(&row)); let popover = gtk::Popover::new(); popover.set_parent(&row); - let download_button = gtk::Button::with_label("다운로드..."); - download_button.set_margin_top(6); - download_button.set_margin_bottom(6); - download_button.set_margin_start(6); - download_button.set_margin_end(6); - popover.set_child(Some(&download_button)); - let controller = Rc::clone(self); - let popover_to_close = popover.clone(); - download_button.connect_clicked(move |_| { - popover_to_close.popdown(); - controller.choose_download_destination(entry.clone()); - }); + let actions = gtk::Box::new(gtk::Orientation::Vertical, 4); + actions.set_margin_top(6); + actions.set_margin_bottom(6); + actions.set_margin_start(6); + actions.set_margin_end(6); + if can_favorite { + let is_favorite = self.is_favorite(&entry.uri); + let favorite_button = gtk::Button::with_label(if is_favorite { + "즐겨찾기 제거" + } else { + "즐겨찾기 추가" + }); + actions.append(&favorite_button); + let controller = Rc::clone(self); + let popover_to_close = popover.clone(); + let favorite_entry = entry.clone(); + favorite_button.connect_clicked(move |_| { + popover_to_close.popdown(); + let profile = controller.current_profile(); + controller.toggle_favorite(&favorite_entry, &profile); + }); + } + if can_download { + let download_button = gtk::Button::with_label("다운로드..."); + actions.append(&download_button); + let controller = Rc::clone(self); + let popover_to_close = popover.clone(); + download_button.connect_clicked(move |_| { + popover_to_close.popdown(); + controller.choose_download_destination(entry.clone()); + }); + } + popover.set_child(Some(&actions)); popover.popup(); } + fn is_favorite(&self, uri: &str) -> bool { + self.config + .borrow() + .favorites + .iter() + .any(|favorite| favorite.uri == uri) + } + + fn favorite_control( + self: &Rc, + entry: &BrowserEntry, + profile: &LocationProfile, + ) -> FavoriteControl { + let entry = entry.clone(); + let profile = profile.clone(); + let controller = Rc::downgrade(self); + FavoriteControl { + active: self.is_favorite(&entry.uri), + toggle: Rc::new(move || { + let Some(controller) = controller.upgrade() else { + return false; + }; + controller.toggle_favorite(&entry, &profile) + }), + } + } + + fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool { + let mut updated = self.config.borrow().clone(); + let was_active = self.is_favorite(&entry.uri); + let active = toggle_favorite_entry(&mut updated, entry, profile); + if let Err(error) = updated.save(&self.config_path) { + self.status_label + .set_text(&format!("즐겨찾기를 저장할 수 없습니다: {error:#}")); + return was_active; + } + *self.config.borrow_mut() = updated; + self.rebuild_favorites(); + self.status_label.set_text(if active { + "즐겨찾기에 추가했습니다." + } else { + "즐겨찾기에서 제거했습니다." + }); + active + } + fn choose_download_destination(self: &Rc, entry: BrowserEntry) { let dialog = gtk::FileDialog::builder() .title("다운로드 위치 선택") @@ -1008,6 +1216,29 @@ fn download_part_path(final_path: &std::path::Path) -> PathBuf { )) } +fn toggle_favorite_entry( + config: &mut AppConfig, + entry: &BrowserEntry, + profile: &LocationProfile, +) -> bool { + if let Some(index) = config + .favorites + .iter() + .position(|favorite| favorite.uri == entry.uri) + { + config.favorites.remove(index); + false + } else { + config.favorites.push(FavoriteEntry { + name: entry.name.clone(), + uri: entry.uri.clone(), + is_directory: entry.kind == EntryKind::Directory, + profile: profile.clone(), + }); + true + } +} + #[cfg(test)] mod tests { use super::*; @@ -1053,4 +1284,19 @@ mod tests { PathBuf::from("/tmp/만화.zip.part") ); } + + #[test] + fn favorite_toggle_adds_and_removes_the_same_uri() { + let mut config = AppConfig::default(); + let entry = entry("Comics"); + let profile = home_profile(); + + assert!(toggle_favorite_entry(&mut config, &entry, &profile)); + assert_eq!(config.favorites.len(), 1); + assert!(config.favorites[0].is_directory); + assert_eq!(config.favorites[0].profile.address, profile.address); + + assert!(!toggle_favorite_entry(&mut config, &entry, &profile)); + assert!(config.favorites.is_empty()); + } }