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