Release v0.2.2
This commit is contained in:
parent
048df6355e
commit
b31acd3f58
8 changed files with 309 additions and 16 deletions
50
AGENTS.md
50
AGENTS.md
|
|
@ -3,8 +3,8 @@
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Comicviewer is a desktop comic archive viewer for CachyOS and Arch Linux.
|
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
|
It browses local, SMB, FTP, and SFTP locations and opens ZIP archives,
|
||||||
ZIP archives.
|
standalone images, and videos.
|
||||||
|
|
||||||
## Technology
|
## Technology
|
||||||
|
|
||||||
|
|
@ -20,10 +20,20 @@ ZIP archives.
|
||||||
- Store credentials as plain text in the application configuration as requested,
|
- Store credentials as plain text in the application configuration as requested,
|
||||||
but restrict the configuration file permissions to `0600`.
|
but restrict the configuration file permissions to `0600`.
|
||||||
- Cache remote directory metadata and refresh it asynchronously when browsing.
|
- 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.
|
- Download remote ZIP archives before opening them.
|
||||||
- Limit the persistent ZIP cache with a user-configurable LRU quota. The default
|
- 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.
|
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.
|
- Sort archive images and browser entries using natural filename ordering.
|
||||||
- Support ascending and descending sorting by name, modification time, and
|
- Support ascending and descending sorting by name, modification time, and
|
||||||
creation time. Treat unavailable creation times as unknown rather than
|
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.
|
- 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
|
- Support both right-to-left and left-to-right two-page layouts, plus controls
|
||||||
that advance by one page to adjust page pairing.
|
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.
|
- Save progress per ZIP and save the most recently read ZIP for resume support.
|
||||||
|
|
||||||
## Data Locations
|
## Data Locations
|
||||||
|
|
@ -50,11 +71,23 @@ Do not write application state into the source tree.
|
||||||
- Prefer the smallest correct implementation and avoid speculative abstractions.
|
- Prefer the smallest correct implementation and avoid speculative abstractions.
|
||||||
- Keep network and archive work off the GTK main thread.
|
- Keep network and archive work off the GTK main thread.
|
||||||
- Show cached directory data immediately, then update it in the background.
|
- 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
|
- Use atomic writes for configuration, database migrations, and completed remote
|
||||||
downloads. Incomplete downloads must use a distinct temporary suffix.
|
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
|
- Do not extract an entire archive to disk. Decode only requested images and
|
||||||
prefetch a small window around the current page.
|
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.
|
- Never trust archive paths or use them as extraction destinations.
|
||||||
- Preserve errors from GIO, ZIP parsing, and image decoding and present concise,
|
- Preserve errors from GIO, ZIP parsing, and image decoding and present concise,
|
||||||
actionable messages in the UI.
|
actionable messages in the UI.
|
||||||
|
|
@ -74,8 +107,13 @@ cargo clippy --all-targets --all-features -- -D warnings
|
||||||
cargo test --all-features
|
cargo test --all-features
|
||||||
```
|
```
|
||||||
|
|
||||||
Add focused tests for natural sorting, cache eviction, metadata refresh,
|
Add focused tests for natural sorting, oldest-first cache eviction, pin and
|
||||||
archive filtering, reading progress, and remote file change detection.
|
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
|
## Git Practices
|
||||||
|
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "comicviewer"
|
name = "comicviewer"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "comicviewer"
|
name = "comicviewer"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "A comic archive viewer for local and remote locations"
|
description = "A comic archive viewer for local and remote locations"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
</provides>
|
</provides>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.2.2" date="2026-07-22" />
|
||||||
<release version="0.2.1" date="2026-07-22" />
|
<release version="0.2.1" date="2026-07-22" />
|
||||||
<release version="0.2.0" date="2026-07-21" />
|
<release version="0.2.0" date="2026-07-21" />
|
||||||
<release version="0.1.4" date="2026-07-21" />
|
<release version="0.1.4" date="2026-07-21" />
|
||||||
|
|
|
||||||
|
|
@ -92,5 +92,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
|
||||||
- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기
|
- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기
|
||||||
- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기
|
- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기
|
||||||
- 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드
|
- 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드
|
||||||
|
- 메인 창이나 뷰어에 로컬 파일 드롭: 부모 폴더를 열고 새 뷰어에서 해당 파일 열기
|
||||||
|
- 메인 창이나 뷰어에 로컬 폴더 드롭: 메인 창에서 해당 폴더 열기
|
||||||
|
|
||||||
애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다.
|
애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다.
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
|
||||||
|
|
||||||
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
|
스크립트는 `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`
|
||||||
- `dist/Comicviewer-latest-x86_64.tar.gz.sha256`
|
- `dist/Comicviewer-latest-x86_64.tar.gz.sha256`
|
||||||
|
|
||||||
|
|
@ -78,10 +78,10 @@ sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git add Cargo.toml Cargo.lock data packaging README.md docs
|
git add Cargo.toml Cargo.lock data packaging README.md docs
|
||||||
git commit -m "Prepare v0.2.1 release"
|
git commit -m "Prepare v0.2.2 release"
|
||||||
git tag -a v0.2.1 -m "Comicviewer v0.2.1"
|
git tag -a v0.2.2 -m "Comicviewer v0.2.2"
|
||||||
git push origin main
|
git push origin main
|
||||||
git push origin v0.2.1
|
git push origin v0.2.2
|
||||||
```
|
```
|
||||||
|
|
||||||
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
|
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
|
||||||
|
|
|
||||||
|
|
@ -182,10 +182,36 @@ pub struct FileViewerControls {
|
||||||
pub sibling_navigator: Option<SiblingNavigator>,
|
pub sibling_navigator: Option<SiblingNavigator>,
|
||||||
pub favorite_control: FavoriteControl,
|
pub favorite_control: FavoriteControl,
|
||||||
pub download_control: FileDownloadControl,
|
pub download_control: FileDownloadControl,
|
||||||
|
pub file_drop_control: FileDropControl,
|
||||||
pub reuse_window: Option<gtk::ApplicationWindow>,
|
pub reuse_window: Option<gtk::ApplicationWindow>,
|
||||||
pub view_mode_control: Option<ViewModeControl>,
|
pub view_mode_control: Option<ViewModeControl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FileDropControl {
|
||||||
|
pub handle: Rc<dyn Fn(Vec<gtk::gio::File>, 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::<gdk::FileList>() 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 {
|
impl ViewerWindow {
|
||||||
pub fn open(
|
pub fn open(
|
||||||
application: >k::Application,
|
application: >k::Application,
|
||||||
|
|
@ -200,6 +226,7 @@ impl ViewerWindow {
|
||||||
sibling_navigator,
|
sibling_navigator,
|
||||||
favorite_control,
|
favorite_control,
|
||||||
download_control,
|
download_control,
|
||||||
|
file_drop_control,
|
||||||
reuse_window,
|
reuse_window,
|
||||||
view_mode_control: _,
|
view_mode_control: _,
|
||||||
} = controls;
|
} = controls;
|
||||||
|
|
@ -215,6 +242,7 @@ impl ViewerWindow {
|
||||||
.default_height(850)
|
.default_height(850)
|
||||||
.build()
|
.build()
|
||||||
});
|
});
|
||||||
|
install_file_drop_target(&window, file_drop_control);
|
||||||
ensure_viewer_close_handler(&window);
|
ensure_viewer_close_handler(&window);
|
||||||
window.set_title(Some(&title));
|
window.set_title(Some(&title));
|
||||||
let header = gtk::HeaderBar::new();
|
let header = gtk::HeaderBar::new();
|
||||||
|
|
@ -1681,6 +1709,7 @@ impl MediaViewerWindow {
|
||||||
sibling_navigator,
|
sibling_navigator,
|
||||||
favorite_control,
|
favorite_control,
|
||||||
download_control,
|
download_control,
|
||||||
|
file_drop_control,
|
||||||
reuse_window,
|
reuse_window,
|
||||||
view_mode_control,
|
view_mode_control,
|
||||||
} = controls;
|
} = controls;
|
||||||
|
|
@ -1696,6 +1725,7 @@ impl MediaViewerWindow {
|
||||||
.default_height(850)
|
.default_height(850)
|
||||||
.build()
|
.build()
|
||||||
});
|
});
|
||||||
|
install_file_drop_target(&window, file_drop_control);
|
||||||
ensure_viewer_close_handler(&window);
|
ensure_viewer_close_handler(&window);
|
||||||
window.set_title(Some(&title));
|
window.set_title(Some(&title));
|
||||||
let header = gtk::HeaderBar::new();
|
let header = gtk::HeaderBar::new();
|
||||||
|
|
|
||||||
230
src/window.rs
230
src/window.rs
|
|
@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result, bail};
|
||||||
use chrono::{DateTime, Local};
|
use chrono::{DateTime, Local};
|
||||||
use gtk::gio;
|
use gtk::gio;
|
||||||
use gtk::gio::prelude::*;
|
use gtk::gio::prelude::*;
|
||||||
|
|
@ -21,9 +21,9 @@ use crate::source::{
|
||||||
mount_operation, parent_within_root, root_file, validate_profile,
|
mount_operation, parent_within_root, root_file, validate_profile,
|
||||||
};
|
};
|
||||||
use crate::viewer::{
|
use crate::viewer::{
|
||||||
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingFile,
|
FavoriteControl, FileDownloadControl, FileDropControl, FileViewerControls, MediaViewerWindow,
|
||||||
SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, ViewerWindow,
|
SiblingFile, SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl,
|
||||||
set_viewer_header,
|
ViewerWindow, install_file_drop_target, set_viewer_header,
|
||||||
};
|
};
|
||||||
use crate::zip_cache::ZipCache;
|
use crate::zip_cache::ZipCache;
|
||||||
|
|
||||||
|
|
@ -171,6 +171,7 @@ impl ComicWindow {
|
||||||
case_insensitive_check,
|
case_insensitive_check,
|
||||||
fast_loading_switch,
|
fast_loading_switch,
|
||||||
);
|
);
|
||||||
|
install_file_drop_target(&controller.window, controller.file_drop_control());
|
||||||
controller.refresh();
|
controller.refresh();
|
||||||
Ok(Self { controller })
|
Ok(Self { controller })
|
||||||
}
|
}
|
||||||
|
|
@ -956,6 +957,7 @@ impl BrowserController {
|
||||||
sibling_navigator: Some(navigator),
|
sibling_navigator: Some(navigator),
|
||||||
favorite_control,
|
favorite_control,
|
||||||
download_control,
|
download_control,
|
||||||
|
file_drop_control: controller.file_drop_control(),
|
||||||
reuse_window,
|
reuse_window,
|
||||||
view_mode_control: None,
|
view_mode_control: None,
|
||||||
},
|
},
|
||||||
|
|
@ -1019,6 +1021,7 @@ impl BrowserController {
|
||||||
sibling_navigator: Some(navigator),
|
sibling_navigator: Some(navigator),
|
||||||
favorite_control,
|
favorite_control,
|
||||||
download_control,
|
download_control,
|
||||||
|
file_drop_control: controller.file_drop_control(),
|
||||||
reuse_window,
|
reuse_window,
|
||||||
view_mode_control,
|
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 {
|
fn view_mode_control(self: &Rc<Self>) -> ViewModeControl {
|
||||||
let selected = self.config.borrow().viewer_mode.dropdown_index();
|
let selected = self.config.borrow().viewer_mode.dropdown_index();
|
||||||
let controller = Rc::downgrade(self);
|
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: >k::Window, message: &str) {
|
||||||
|
gtk::AlertDialog::builder()
|
||||||
|
.modal(true)
|
||||||
|
.message(message)
|
||||||
|
.build()
|
||||||
|
.show(Some(parent));
|
||||||
|
}
|
||||||
|
|
||||||
fn display_uri(uri: &str) -> String {
|
fn display_uri(uri: &str) -> String {
|
||||||
let file = gio::File::for_uri(uri);
|
let file = gio::File::for_uri(uri);
|
||||||
file.path().map_or_else(
|
file.path().map_or_else(
|
||||||
|
|
@ -1682,6 +1850,24 @@ fn sorted_navigable_entries(
|
||||||
siblings
|
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 {
|
fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry {
|
||||||
BrowserEntry {
|
BrowserEntry {
|
||||||
uri: favorite.uri.clone(),
|
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]
|
#[test]
|
||||||
fn favorites_are_rendered_as_browser_paths_and_files() {
|
fn favorites_are_rendered_as_browser_paths_and_files() {
|
||||||
let profile = home_profile();
|
let profile = home_profile();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue