diff --git a/AGENTS.md b/AGENTS.md index 64ce761..9d88581 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,8 @@ ## Project Overview Comicviewer is a desktop comic archive viewer for CachyOS and Arch Linux. -It browses local, SMB, FTP, and SFTP locations and opens image files stored in -ZIP archives. +It browses local, SMB, FTP, and SFTP locations and opens ZIP archives, +standalone images, and videos. ## Technology @@ -20,10 +20,20 @@ ZIP archives. - Store credentials as plain text in the application configuration as requested, but restrict the configuration file permissions to `0600`. - Cache remote directory metadata and refresh it asynchronously when browsing. +- Cancel a superseded remote directory request so only the latest request can + consume resources, update progress, or replace browser entries. - Download remote ZIP archives before opening them. - Limit the persistent ZIP cache with a user-configurable LRU quota. The default is 10 GB. A quota of 0 disables persistent ZIP caching and uses temporary files. -- Support JPEG, PNG, WebP, static GIF, BMP, and AVIF images in ZIP archives. +- Evict the oldest unpinned ZIP cache files before a download needs space, at + startup, and after an open archive releases its cache pin. Allow users to clear + cached ZIPs without deleting open archives or reading history. +- Keep temporary remote media in the application cache directory, remove it when + its viewer closes, and clean stale temporary downloads on the next startup. +- Support JPEG, PNG, WebP, GIF, BMP, and AVIF images both standalone and in ZIP + archives. Play animated GIF and WebP frames with their delays and loop counts. +- Support common standalone video formats through GStreamer. In video playback, + Left/Right seek by five seconds and Up/Down adjust volume by five percent. - Sort archive images and browser entries using natural filename ordering. - Support ascending and descending sorting by name, modification time, and creation time. Treat unavailable creation times as unknown rather than @@ -33,6 +43,17 @@ ZIP archives. - Provide 100%, fit-width, fit-height, two-page, and continuous vertical viewing. - Support both right-to-left and left-to-right two-page layouts, plus controls that advance by one page to adjust page pairing. +- Reuse viewer windows when moving between ZIP, image, and video files. Preserve + fullscreen state and keep the registered GTK titlebar widget stable while the + displayed header changes. +- Provide a toggleable file list on the right side of each viewer. Double-click + or Enter opens the selected viewable file; its context menu provides favorite + and download actions. +- Accept one local file or folder dropped onto the main window or a viewer. + Browse dropped folders in the main window; for dropped files, browse the + parent folder and open the exact naturally sorted item in a new viewer window. +- Save favorites for local and remote folders and files, and preserve the saved + location profile needed to reconnect to remote favorites. - Save progress per ZIP and save the most recently read ZIP for resume support. ## Data Locations @@ -50,11 +71,23 @@ Do not write application state into the source tree. - Prefer the smallest correct implementation and avoid speculative abstractions. - Keep network and archive work off the GTK main thread. - Show cached directory data immediately, then update it in the background. +- Abort superseded GIO futures and guard asynchronous progress and completion + callbacks against stale generations or closed/reused windows. +- Handle file drops as `COPY` actions through `GdkFileList`. Inspect local paths + asynchronously, reject ambiguous/multiple drops, and never reuse the source + viewer window for a dropped file. - Use atomic writes for configuration, database migrations, and completed remote downloads. Incomplete downloads must use a distinct temporary suffix. +- Reserve persistent cache capacity before writing a known-size remote ZIP. + Cache cleanup must skip active temporary downloads and pinned/open archives. - Do not extract an entire archive to disk. Decode only requested images and prefetch a small window around the current page. -- Validate archive entry sizes and image dimensions before allocation. +- Validate archive entry sizes, image dimensions, animation frame counts, and + cumulative decoded animation size before retaining decoded data. +- Cancel animation timers when pictures are hidden, viewers are replaced, or + windows close. GTK/GDK objects must only be created on the GTK main thread. +- Avoid strong `Rc` cycles between GTK signal handlers and controllers. Viewer + teardown must release temporary files, ZIP cache pins, textures, and timers. - Never trust archive paths or use them as extraction destinations. - Preserve errors from GIO, ZIP parsing, and image decoding and present concise, actionable messages in the UI. @@ -74,8 +107,13 @@ cargo clippy --all-targets --all-features -- -D warnings cargo test --all-features ``` -Add focused tests for natural sorting, cache eviction, metadata refresh, -archive filtering, reading progress, and remote file change detection. +Add focused tests for natural sorting, oldest-first cache eviction, pin and +temporary-file protection, metadata refresh, archive filtering, animated image +decoding, video seek/volume calculations, viewer sibling navigation, reading +progress, dropped-file sorting/index selection, and remote file change +detection. For UI or packaging changes, also run a GTK startup smoke test with +isolated XDG directories; release builds must verify the AppImage and downloaded +release bundle checksums. ## Git Practices diff --git a/Cargo.lock b/Cargo.lock index 8696120..43d3758 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 72c8f1c..ee19cc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.1" +version = "0.2.2" edition = "2024" description = "A comic archive viewer for local and remote locations" license = "GPL-3.0-or-later" diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml index 84e954c..7feae22 100644 --- a/data/io.github.comicviewer.Comicviewer.metainfo.xml +++ b/data/io.github.comicviewer.Comicviewer.metainfo.xml @@ -26,6 +26,7 @@ + diff --git a/docs/INSTALL.ko.md b/docs/INSTALL.ko.md index a116d4c..1094e7f 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -92,5 +92,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - `파일 목록`: 우측 폴더 목록 표시 또는 숨기기 - 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기 - 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드 +- 메인 창이나 뷰어에 로컬 파일 드롭: 부모 폴더를 열고 새 뷰어에서 해당 파일 열기 +- 메인 창이나 뷰어에 로컬 폴더 드롭: 메인 창에서 해당 폴더 열기 애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다. diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index 8f8694b..0225d74 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.1-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.2-x86_64.AppImage.sha256` - `dist/Comicviewer-latest-x86_64.tar.gz` - `dist/Comicviewer-latest-x86_64.tar.gz.sha256` @@ -78,10 +78,10 @@ sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256 ```sh git add Cargo.toml Cargo.lock data packaging README.md docs -git commit -m "Prepare v0.2.1 release" -git tag -a v0.2.1 -m "Comicviewer v0.2.1" +git commit -m "Prepare v0.2.2 release" +git tag -a v0.2.2 -m "Comicviewer v0.2.2" git push origin main -git push origin v0.2.1 +git push origin v0.2.2 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index 883ab74..40f30e9 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -182,10 +182,36 @@ pub struct FileViewerControls { pub sibling_navigator: Option, pub favorite_control: FavoriteControl, pub download_control: FileDownloadControl, + pub file_drop_control: FileDropControl, pub reuse_window: Option, pub view_mode_control: Option, } +#[derive(Clone)] +pub struct FileDropControl { + pub handle: Rc, gtk::Window)>, +} + +pub(crate) fn install_file_drop_target(window: >k::ApplicationWindow, control: FileDropControl) { + if window.has_css_class("comicviewer-file-drop-target") { + return; + } + window.add_css_class("comicviewer-file-drop-target"); + let target = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::COPY); + let weak_window = window.downgrade(); + target.connect_drop(move |_, value, _, _| { + let Ok(file_list) = value.get::() else { + return false; + }; + let Some(window) = weak_window.upgrade() else { + return false; + }; + (control.handle)(file_list.files(), window.upcast()); + true + }); + window.add_controller(target); +} + impl ViewerWindow { pub fn open( application: >k::Application, @@ -200,6 +226,7 @@ impl ViewerWindow { sibling_navigator, favorite_control, download_control, + file_drop_control, reuse_window, view_mode_control: _, } = controls; @@ -215,6 +242,7 @@ impl ViewerWindow { .default_height(850) .build() }); + install_file_drop_target(&window, file_drop_control); ensure_viewer_close_handler(&window); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); @@ -1681,6 +1709,7 @@ impl MediaViewerWindow { sibling_navigator, favorite_control, download_control, + file_drop_control, reuse_window, view_mode_control, } = controls; @@ -1696,6 +1725,7 @@ impl MediaViewerWindow { .default_height(850) .build() }); + install_file_drop_target(&window, file_drop_control); ensure_viewer_close_handler(&window); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); diff --git a/src/window.rs b/src/window.rs index e60c6d3..593370e 100644 --- a/src/window.rs +++ b/src/window.rs @@ -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) -> 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, files: Vec, 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, + ) { + 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) -> 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 { + 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: >k::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, 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();