Add local and remote favorites

This commit is contained in:
burnintuna 2026-07-22 01:24:37 +09:00
parent 5f9b5d9796
commit 490c925f62
4 changed files with 357 additions and 28 deletions

View file

@ -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(&gtk::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<LocationProfile>,
root_uri: String,
current_uri: String,
entries: Vec<BrowserEntry>,
@ -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(&gtk::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(&gtk::StringList::new(&names)));
self.favorite_dropdown.set_selected(0);
}
fn select_profile(self: &Rc<Self>) {
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<Self>) {
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<Self>) {
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<Vec<BrowserEntry>>,
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<Self>,
siblings: Rc<Vec<BrowserEntry>>,
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<Vec<BrowserEntry>>,
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<Vec<BrowserEntry>>,
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<Self>,
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<Self>, 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());
}
}