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

@ -11,6 +11,7 @@ pub struct AppConfig {
pub cache_quota_gb: u64,
pub fast_image_loading: bool,
pub locations: Vec<LocationProfile>,
pub favorites: Vec<FavoriteEntry>,
}
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());
}
}

View file

@ -177,6 +177,26 @@ pub fn mount_operation(
operation
}
pub async fn ensure_mounted(
file: &gio::File,
profile: &LocationProfile,
operation: &gtk::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: &gtk::MountOperation,

View file

@ -23,6 +23,11 @@ pub struct ViewerWindow;
pub type SiblingNavigator = Rc<dyn Fn(bool) -> bool>;
pub struct FavoriteControl {
pub active: bool,
pub toggle: Rc<dyn Fn() -> bool>,
}
impl ViewerWindow {
pub fn open(
application: &gtk::Application,
@ -30,6 +35,7 @@ impl ViewerWindow {
database: Rc<Database>,
fast_image_loading: bool,
sibling_navigator: Option<SiblingNavigator>,
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(&gtk::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<SiblingNavigator>,
favorite_button: gtk::Button,
favorite_toggle: Rc<dyn Fn() -> bool>,
state: RefCell<ViewerState>,
}
@ -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<SiblingNavigator>,
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(&gtk::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<SiblingNavigator>,
favorite_button: gtk::Button,
favorite_toggle: Rc<dyn Fn() -> 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);

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());
}
}