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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
|||
|
||||
[[package]]
|
||||
name = "comicviewer"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
</provides>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.2.2" 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.1.4" date="2026-07-21" />
|
||||
|
|
|
|||
|
|
@ -92,5 +92,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
|
|||
- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기
|
||||
- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기
|
||||
- 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드
|
||||
- 메인 창이나 뷰어에 로컬 파일 드롭: 부모 폴더를 열고 새 뷰어에서 해당 파일 열기
|
||||
- 메인 창이나 뷰어에 로컬 폴더 드롭: 메인 창에서 해당 폴더 열기
|
||||
|
||||
애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
|
||||
|
|
|
|||
|
|
@ -182,10 +182,36 @@ pub struct FileViewerControls {
|
|||
pub sibling_navigator: Option<SiblingNavigator>,
|
||||
pub favorite_control: FavoriteControl,
|
||||
pub download_control: FileDownloadControl,
|
||||
pub file_drop_control: FileDropControl,
|
||||
pub reuse_window: Option<gtk::ApplicationWindow>,
|
||||
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 {
|
||||
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();
|
||||
|
|
|
|||
230
src/window.rs
230
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<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: >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<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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue