Release v0.2.2

This commit is contained in:
burnintuna 2026-07-22 17:34:37 +09:00
parent 048df6355e
commit b31acd3f58
8 changed files with 309 additions and 16 deletions

View file

@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::{Duration, Instant};
use anyhow::Result;
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Local};
use gtk::gio;
use gtk::gio::prelude::*;
@ -21,9 +21,9 @@ use crate::source::{
mount_operation, parent_within_root, root_file, validate_profile,
};
use crate::viewer::{
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingFile,
SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, ViewerWindow,
set_viewer_header,
FavoriteControl, FileDownloadControl, FileDropControl, FileViewerControls, MediaViewerWindow,
SiblingFile, SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl,
ViewerWindow, install_file_drop_target, set_viewer_header,
};
use crate::zip_cache::ZipCache;
@ -171,6 +171,7 @@ impl ComicWindow {
case_insensitive_check,
fast_loading_switch,
);
install_file_drop_target(&controller.window, controller.file_drop_control());
controller.refresh();
Ok(Self { controller })
}
@ -956,6 +957,7 @@ impl BrowserController {
sibling_navigator: Some(navigator),
favorite_control,
download_control,
file_drop_control: controller.file_drop_control(),
reuse_window,
view_mode_control: None,
},
@ -1019,6 +1021,7 @@ impl BrowserController {
sibling_navigator: Some(navigator),
favorite_control,
download_control,
file_drop_control: controller.file_drop_control(),
reuse_window,
view_mode_control,
},
@ -1147,6 +1150,140 @@ impl BrowserController {
}
}
fn file_drop_control(self: &Rc<Self>) -> FileDropControl {
let controller = Rc::downgrade(self);
FileDropControl {
handle: Rc::new(move |files, parent| {
if let Some(controller) = controller.upgrade() {
controller.open_dropped_files(files, parent);
}
}),
}
}
fn open_dropped_files(self: &Rc<Self>, files: Vec<gio::File>, parent: gtk::Window) {
let [file] = files.as_slice() else {
show_drop_error(&parent, "한 번에 하나의 파일 또는 폴더만 열 수 있습니다.");
return;
};
if !file.is_native() {
show_drop_error(&parent, "로컬 파일 또는 폴더만 드래그해서 열 수 있습니다.");
return;
}
let file = file.clone();
self.cancel_listing();
let generation = {
let mut state = self.state.borrow_mut();
state.generation = state.generation.wrapping_add(1);
state.generation
};
self.status_label.set_text("드롭한 항목을 확인하는 중...");
let controller = Rc::downgrade(self);
let task = glib::MainContext::default().spawn_local(async move {
let result: Result<_> = async {
let info = file
.query_info_future(
"standard::type,standard::display-name",
gio::FileQueryInfoFlags::NONE,
glib::Priority::DEFAULT,
)
.await
.context("드롭한 항목을 확인할 수 없습니다")?;
let selected = match info.file_type() {
gio::FileType::Directory => None,
gio::FileType::Regular => {
if !classify_file(Path::new(info.display_name().as_str())).is_viewable() {
bail!("지원하지 않는 파일 형식입니다: {}", info.display_name());
}
Some(file.clone())
}
_ => bail!("일반 파일 또는 폴더만 열 수 있습니다"),
};
let directory = selected
.as_ref()
.and_then(gio::File::parent)
.unwrap_or_else(|| file.clone());
let profile = local_profile_for_directory(&directory)?;
let operation = mount_operation(&profile, &parent);
let entries =
enumerate_directory(&profile, directory.uri().as_str(), &operation, |_| {})
.await?;
Ok((directory, profile, entries, selected))
}
.await;
let Some(controller) = controller.upgrade() else {
return;
};
if controller.state.borrow().generation != generation || !parent.is_visible() {
return;
}
let (directory, profile, entries, selected) = match result {
Ok(result) => result,
Err(error) => {
let message = format!("드롭한 항목을 열 수 없습니다: {error:#}");
controller.status_label.set_text(&message);
show_drop_error(&parent, &message);
return;
}
};
controller.show_dropped_directory(&directory, profile.clone(), entries.clone());
let Some(selected) = selected else {
return;
};
let Some(entry) = entries
.iter()
.find(|entry| gio::File::for_uri(&entry.uri).equal(&selected))
.cloned()
else {
show_drop_error(&parent, "드롭한 파일을 부모 폴더에서 찾을 수 없습니다.");
return;
};
let state = controller.state.borrow();
let Some((siblings, _selected_index)) = sorted_navigable_selection(
&entries,
&entry.uri,
state.sort_field,
state.sort_direction,
controller.config.borrow().case_insensitive_sort,
) else {
drop(state);
show_drop_error(&parent, "드롭한 파일의 목록 위치를 확인할 수 없습니다.");
return;
};
drop(state);
controller.open_viewable(entry, Rc::new(siblings), profile, None);
});
self.listing_task.replace(Some(task));
}
fn show_dropped_directory(
&self,
directory: &gio::File,
profile: LocationProfile,
entries: Vec<BrowserEntry>,
) {
self.location_dropdown
.set_selected(gtk::INVALID_LIST_POSITION);
self.edit_location_button.set_sensitive(false);
self.delete_location_button.set_sensitive(false);
self.path_entry.set_editable(true);
let uri: String = directory.uri().into();
let mut state = self.state.borrow_mut();
state.favorite_profile = Some(profile);
state.browsing_favorites = false;
state.root_uri = uri.clone();
state.current_uri = uri.clone();
state.entries = entries;
state.typeahead_query.clear();
state.typeahead_last_input = None;
drop(state);
self.path_entry.set_text(&display_uri(&uri));
self.search_entry.set_text("");
self.render();
self.window.present();
}
fn view_mode_control(self: &Rc<Self>) -> ViewModeControl {
let selected = self.config.borrow().viewer_mode.dropdown_index();
let controller = Rc::downgrade(self);
@ -1599,6 +1736,37 @@ fn home_profile() -> LocationProfile {
}
}
fn local_profile_for_directory(directory: &gio::File) -> Result<LocationProfile> {
let path = directory
.path()
.context("로컬 폴더 경로를 확인할 수 없습니다")?;
let address = path
.to_str()
.context("UTF-8로 표현할 수 없는 로컬 경로는 열 수 없습니다")?;
let name = path
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(address)
.to_owned();
Ok(LocationProfile {
name,
kind: LocationKind::Local,
address: address.to_owned(),
username: String::new(),
password: String::new(),
domain: String::new(),
})
}
fn show_drop_error(parent: &gtk::Window, message: &str) {
gtk::AlertDialog::builder()
.modal(true)
.message(message)
.build()
.show(Some(parent));
}
fn display_uri(uri: &str) -> String {
let file = gio::File::for_uri(uri);
file.path().map_or_else(
@ -1682,6 +1850,24 @@ fn sorted_navigable_entries(
siblings
}
fn sorted_navigable_selection(
entries: &[BrowserEntry],
selected_uri: &str,
sort_field: SortField,
sort_direction: SortDirection,
case_insensitive: bool,
) -> Option<(Vec<BrowserEntry>, usize)> {
let entries = sorted_navigable_entries(
entries,
EntryKind::Image,
sort_field,
sort_direction,
case_insensitive,
);
let selected = entries.iter().position(|entry| entry.uri == selected_uri)?;
Some((entries, selected))
}
fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry {
BrowserEntry {
uri: favorite.uri.clone(),
@ -1835,6 +2021,42 @@ mod tests {
);
}
#[test]
fn dropped_file_selection_uses_sorted_parent_index() {
let entries = [
media_entry("page10.jpg"),
entry("folder"),
media_entry("page2.jpg"),
];
let selected_uri = entries[0].uri.clone();
let (siblings, selected) = sorted_navigable_selection(
&entries,
&selected_uri,
SortField::Name,
SortDirection::Ascending,
true,
)
.unwrap();
assert_eq!(siblings.len(), 2);
assert_eq!(siblings[0].name, "page2.jpg");
assert_eq!(siblings[selected].name, "page10.jpg");
}
#[test]
fn dropped_directory_creates_local_root_profile() {
let directory = tempfile::tempdir().unwrap();
let file = gio::File::for_path(directory.path());
let profile = local_profile_for_directory(&file).unwrap();
assert_eq!(profile.kind, LocationKind::Local);
assert_eq!(Path::new(&profile.address), directory.path());
assert!(profile.username.is_empty());
assert!(profile.password.is_empty());
}
#[test]
fn favorites_are_rendered_as_browser_paths_and_files() {
let profile = home_profile();