├── rustfmt.toml ├── crates ├── kiorg │ ├── src │ │ ├── utils │ │ │ ├── mod.rs │ │ │ ├── file_operations.rs │ │ │ └── icon.rs │ │ ├── models │ │ │ ├── mod.rs │ │ │ └── dir_entry.rs │ │ ├── lib.rs │ │ ├── ui │ │ │ ├── mod.rs │ │ │ ├── style.rs │ │ │ ├── separator.rs │ │ │ ├── popup │ │ │ │ ├── window_utils.rs │ │ │ │ ├── exit.rs │ │ │ │ ├── generic_message.rs │ │ │ │ ├── about.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── sort_toggle.rs │ │ │ │ ├── open_with.rs │ │ │ │ ├── utils.rs │ │ │ │ ├── preview │ │ │ │ │ └── image.rs │ │ │ │ └── plugin.rs │ │ │ ├── egui_notify │ │ │ │ └── anchor.rs │ │ │ ├── preview │ │ │ │ ├── directory.rs │ │ │ │ ├── zip.rs │ │ │ │ ├── loading.rs │ │ │ │ ├── plugin.rs │ │ │ │ └── tar.rs │ │ │ ├── notification.rs │ │ │ ├── left_panel.rs │ │ │ ├── top_banner.rs │ │ │ └── terminal.rs │ │ ├── plugins │ │ │ └── mod.rs │ │ ├── notifications │ │ │ └── anchor.rs │ │ ├── open_wrap.rs │ │ ├── font.rs │ │ ├── main.rs │ │ └── startup_error.rs │ ├── Cargo.toml │ └── benches │ │ └── center_panel_bench.rs └── kiorg_plugin │ ├── examples │ └── demo_plugin │ │ ├── Cargo.toml │ │ ├── README.md │ │ └── src │ │ └── main.rs │ ├── Cargo.toml │ └── README.md ├── assets └── icons │ └── 1024x1024@2x.png ├── tests ├── snapshots │ ├── help_menu.png │ └── theme_selection_animation.gif ├── ui_volumes_test.rs ├── ui_windows_drives_test.rs ├── ui_terminal_test.rs ├── initial_path_test.rs ├── ui_help_test.rs ├── ui_toggle_hidden_files_test.rs ├── ui_unmark_cut_file_test.rs ├── ui_rename_selection_test.rs ├── ui_pdf_page_count_test.rs ├── ui_teleport_popup_test.rs ├── ui_remove_selection_test.rs ├── ui_file_list_refresh_selection_preservation.rs ├── ui_sort_by_name_test.rs ├── ui_page_down_small_list_test.rs ├── ui_tab_navigation_test.rs ├── ui_exit_popup_test.rs ├── ui_bulk_delete_test.rs ├── ui_rename_popup_test.rs ├── ui_goto_entry_with_filter_test.rs ├── ui_sort_navigation_bug_test.rs ├── ui_file_selection_test.rs ├── ui_bookmark_test.rs └── ui_epub_preview_test.rs ├── .gitmodules ├── .cargo └── config.toml ├── .github ├── prompts │ └── lint.prompt.md └── workflows │ └── clean_disk_space.sh ├── .gitignore ├── plugins └── heif │ └── Cargo.toml ├── memory-bank ├── techContext.md ├── productContext.md ├── ui_style_guide.md ├── progress.md ├── systemPatterns.md ├── projectbrief.md └── activeContext.md ├── LICENSE ├── Cargo.toml ├── README.md └── .ai_instructions.md /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2024" 2 | -------------------------------------------------------------------------------- /crates/kiorg/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod file_operations; 2 | pub mod icon; 3 | pub mod rollback; 4 | -------------------------------------------------------------------------------- /assets/icons/1024x1024@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houqp/kiorg/HEAD/assets/icons/1024x1024@2x.png -------------------------------------------------------------------------------- /tests/snapshots/help_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houqp/kiorg/HEAD/tests/snapshots/help_menu.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "assets/pdf_fonts"] 2 | path = assets/pdf_fonts 3 | url = https://github.com/s3bk/pdf_fonts.git 4 | -------------------------------------------------------------------------------- /crates/kiorg/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod action_history; 2 | pub mod dir_entry; 3 | pub mod preview_content; 4 | pub mod tab; 5 | -------------------------------------------------------------------------------- /tests/snapshots/theme_selection_animation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/houqp/kiorg/HEAD/tests/snapshots/theme_selection_animation.gif -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | STANDARD_FONTS = { value = "assets/pdf_fonts", relative = true } 3 | 4 | [target.x86_64-unknown-linux-gnu] 5 | rustflags = ["-C", "link-arg=-fuse-ld=lld"] 6 | -------------------------------------------------------------------------------- /.github/prompts/lint.prompt.md: -------------------------------------------------------------------------------- 1 | --- 2 | mode: agent 3 | --- 4 | 5 | ### Instructions 6 | 7 | Fix all clippy errors from `cargo clippy --all-targets --all-features -- -D warnings`. 8 | 9 | After clippy runs clean, run `cargo fmt` to format the code. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | tests/snapshots/*.old.png 3 | tests/snapshots/*.diff.png 4 | tests/snapshots/theme_selection-*.png 5 | node_modules 6 | package-lock.json 7 | package.json 8 | assets/raw 9 | scratch_space 10 | examples/**/target/ 11 | -------------------------------------------------------------------------------- /crates/kiorg_plugin/examples/demo_plugin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kiorg_plugin_demo" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [[bin]] 7 | name = "kiorg_plugin_demo" 8 | path = "src/main.rs" 9 | 10 | [dependencies] 11 | kiorg_plugin = { path = "../.." } -------------------------------------------------------------------------------- /crates/kiorg/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod config; 3 | pub mod font; 4 | pub mod input; 5 | pub mod models; 6 | pub mod open_wrap; 7 | pub mod plugins; 8 | pub mod startup_error; 9 | pub mod theme; 10 | pub mod ui; 11 | pub mod utils; 12 | pub mod visit_history; 13 | 14 | pub use app::Kiorg; 15 | -------------------------------------------------------------------------------- /crates/kiorg_plugin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kiorg_plugin" 3 | version = "0.1.0" 4 | edition = "2021" 5 | description = "Plugin framework for kiorg file manager" 6 | license = "MIT OR Apache-2.0" 7 | 8 | [dependencies] 9 | serde = { version = "1.0", features = ["derive"] } 10 | uuid = { version = "1.0", features = ["v4", "serde"] } 11 | rmp-serde = "1.0" -------------------------------------------------------------------------------- /crates/kiorg/src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod center_panel; 2 | pub mod egui_notify; 3 | pub mod file_list; 4 | pub mod help_window; 5 | pub mod left_panel; 6 | pub mod notification; 7 | pub mod path_nav; 8 | pub mod popup; 9 | pub mod preview; 10 | pub mod right_panel; 11 | pub mod search_bar; 12 | pub mod separator; 13 | pub mod style; 14 | pub mod terminal; 15 | pub mod top_banner; 16 | pub mod update; 17 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/style.rs: -------------------------------------------------------------------------------- 1 | use crate::config::colors::AppColors; 2 | 3 | pub const HEADER_FONT_SIZE: f32 = 12.0; 4 | pub const HEADER_ROW_HEIGHT: f32 = HEADER_FONT_SIZE + 4.0; 5 | 6 | #[must_use] 7 | pub fn section_title_text(text: &str, colors: &AppColors) -> egui::RichText { 8 | egui::RichText::new(text) 9 | .color(colors.fg_light) 10 | .font(egui::FontId::proportional(HEADER_FONT_SIZE)) 11 | } 12 | -------------------------------------------------------------------------------- /plugins/heif/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kiorg_plugin_heif" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [[bin]] 7 | name = "kiorg_plugin_heif" 8 | path = "src/main.rs" 9 | 10 | [dependencies] 11 | kiorg_plugin = { path = "../../crates/kiorg_plugin" } 12 | libheif-rs = "2.1" 13 | image = { version = "0.25", default-features = false, features = ["png"] } 14 | exif = { package = "kamadak-exif", version = "0.6.1" } 15 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/separator.rs: -------------------------------------------------------------------------------- 1 | use egui::{Separator, Ui}; 2 | 3 | pub const SEPARATOR_PADDING: f32 = 1.0; 4 | 5 | pub fn draw_vertical_separator(ui: &mut Ui) { 6 | // Add padding argument 7 | ui.vertical(|ui| { 8 | ui.set_min_width(SEPARATOR_PADDING); // Use padding argument 9 | ui.set_max_width(SEPARATOR_PADDING); // Use padding argument 10 | ui.add(Separator::default().vertical()); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /crates/kiorg/src/plugins/mod.rs: -------------------------------------------------------------------------------- 1 | //! Simple plugin system for basic plugin discovery and management 2 | //! 3 | //! This module provides a simplified plugin system for discovering and managing 4 | //! external plugin executables. 5 | 6 | pub mod manager; 7 | 8 | pub use manager::PluginManager; 9 | 10 | // Re-export types from the kiorg_plugin crate 11 | pub use kiorg_plugin::{ 12 | CallId, EngineCommand, EngineMessage, HelloMessage, PluginMetadata, PluginResponse, 13 | }; 14 | -------------------------------------------------------------------------------- /crates/kiorg_plugin/examples/demo_plugin/README.md: -------------------------------------------------------------------------------- 1 | # Demo Kiorg Plugin 2 | 3 | This is a demo plugin demonstrating the simplified Kiorg plugin system. It provides a basic preview command that always returns "hello world". 4 | 5 | ## Building 6 | 7 | ```bash 8 | cargo build --release 9 | ``` 10 | 11 | The plugin binary will be created at `target/release/kiorg_plugin_demo`. 12 | 13 | ## Usage 14 | 15 | The plugin is designed to be executed by the Kiorg plugin system, but can be tested manually: 16 | 17 | ```bash 18 | ./target/release/kiorg_plugin_demo 19 | ``` 20 | -------------------------------------------------------------------------------- /memory-bank/techContext.md: -------------------------------------------------------------------------------- 1 | ## Tech Context 2 | 3 | ### Technologies used 4 | * Rust 5 | * egui 6 | 7 | ### Development setup 8 | * Rust toolchain 9 | * Cargo package manager 10 | * To regenerate screenshots: `UPDATE_SNAPSHOTS=1 cargo test --features=snapshot` 11 | 12 | ### Technical constraints 13 | * egui is a relatively new UI framework, so there may be limitations or bugs. 14 | * Cross-platform compatibility may require platform-specific code. 15 | * Configuration files need to maintain backward compatibility as new preferences are added. 16 | 17 | ### Dependencies 18 | 19 | See `Cargo.toml`. Key platform-specific dependencies: 20 | * windows-sys: Windows system API bindings for drive enumeration and file system operations -------------------------------------------------------------------------------- /tests/ui_volumes_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "macos")] 2 | #[path = "mod/ui_test_helpers.rs"] 3 | mod ui_test_helpers; 4 | 5 | #[cfg(target_os = "macos")] 6 | #[test] 7 | fn test_show_volumes_popup() { 8 | use egui::Key; 9 | use kiorg::ui::popup::PopupType; 10 | use tempfile::tempdir; 11 | use ui_test_helpers::{create_harness, ctrl_shift_modifiers, wait_for_condition}; 12 | 13 | let temp_dir = tempdir().unwrap(); 14 | let mut harness = create_harness(&temp_dir); 15 | 16 | // Initially no popup should be shown 17 | assert!(harness.state().show_popup.is_none()); 18 | 19 | // Simulate Ctrl+Shift+V to open volumes popup 20 | harness.key_press_modifiers(ctrl_shift_modifiers(), Key::V); 21 | 22 | wait_for_condition(|| { 23 | harness.step(); 24 | harness.state().show_popup.is_some() 25 | }); 26 | 27 | if let Some(PopupType::Volumes(_)) = harness.state().show_popup { 28 | // Popup is showing correctly 29 | } else { 30 | panic!("Expected Volumes popup to be showing"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/ui_windows_drives_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "windows")] 2 | #[path = "mod/ui_test_helpers.rs"] 3 | mod ui_test_helpers; 4 | 5 | #[cfg(target_os = "windows")] 6 | #[test] 7 | fn test_show_windows_drives_popup() { 8 | use egui::Key; 9 | use kiorg::ui::popup::PopupType; 10 | use tempfile::tempdir; 11 | use ui_test_helpers::{create_harness, ctrl_shift_modifiers, wait_for_condition}; 12 | 13 | let temp_dir = tempdir().unwrap(); 14 | let mut harness = create_harness(&temp_dir); 15 | 16 | // Initially no popup should be shown 17 | assert!(harness.state().show_popup.is_none()); 18 | 19 | // Simulate Ctrl+Shift+V to open drives popup 20 | harness.key_press_modifiers(ctrl_shift_modifiers(), Key::D); 21 | 22 | wait_for_condition(|| { 23 | harness.step(); 24 | harness.state().show_popup.is_some() 25 | }); 26 | 27 | // Check that drives popup is now showing 28 | if let Some(PopupType::WindowsDrives(_)) = harness.state().show_popup { 29 | // Popup is showing correctly 30 | } else { 31 | panic!("Expected WindowsDrives popup to be showing"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 - QP Hou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the “Software”), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /memory-bank/productContext.md: -------------------------------------------------------------------------------- 1 | ## Product Context 2 | 3 | ### Why this project exists 4 | Kiorg exists to provide a fast, efficient, and user-friendly file management experience, especially for users who prefer keyboard-driven navigation. 5 | 6 | ### Problems it solves 7 | * Slow and clunky file managers with inefficient keyboard navigation. 8 | * Lack of cross-platform support for consistent file management. 9 | * Limited customization options for power users. 10 | 11 | ### How it should work 12 | Kiorg should provide a responsive and intuitive interface with Vim-inspired keybindings for navigation and file operations. It should support customizable color schemes and be cross-platform compatible. Platform-specific features should integrate seamlessly with the operating system while maintaining a consistent user experience across platforms. 13 | 14 | ### User experience goals 15 | * Provide a seamless and efficient file management experience. 16 | * Offer a visually appealing and customizable interface. 17 | * Empower users with keyboard-driven navigation and powerful features. 18 | * Provide rich preview capabilities for common file types with interactive features. 19 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "crates/kiorg", 4 | "crates/kiorg_plugin", 5 | "crates/kiorg_plugin/examples/demo_plugin", 6 | "plugins/heif", 7 | ] 8 | default-members = ["crates/kiorg"] 9 | resolver = "2" 10 | 11 | [workspace.dependencies] 12 | kiorg_plugin = { path = "crates/kiorg_plugin" } 13 | 14 | [patch.'https://github.com/servo/pathfinder'] 15 | pathfinder_geometry = { git = "https://github.com/houqp/pathfinder", rev = "c82663778404c74c0d2cceb42a99b41f6e48e1e4" } 16 | pathfinder_gpu = { git = "https://github.com/houqp/pathfinder", rev = "c82663778404c74c0d2cceb42a99b41f6e48e1e4" } 17 | pathfinder_content = { git = "https://github.com/houqp/pathfinder", rev = "c82663778404c74c0d2cceb42a99b41f6e48e1e4" } 18 | pathfinder_color = { git = "https://github.com/houqp/pathfinder", rev = "c82663778404c74c0d2cceb42a99b41f6e48e1e4" } 19 | pathfinder_renderer = { git = "https://github.com/houqp/pathfinder", rev = "c82663778404c74c0d2cceb42a99b41f6e48e1e4" } 20 | pathfinder_resources = { git = "https://github.com/houqp/pathfinder", rev = "c82663778404c74c0d2cceb42a99b41f6e48e1e4" } 21 | pathfinder_simd = { git = "https://github.com/houqp/pathfinder", rev = "c82663778404c74c0d2cceb42a99b41f6e48e1e4" } 22 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/popup/window_utils.rs: -------------------------------------------------------------------------------- 1 | /// Standard margin for popup content 2 | pub const POPUP_MARGIN: i8 = 10; 3 | 4 | pub fn new_center_popup_window(title: &str) -> egui::Window<'_> { 5 | egui::Window::new(egui::RichText::from(title).size(14.0)) 6 | .collapsible(false) 7 | .resizable(false) 8 | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) 9 | } 10 | 11 | /// Show a centered popup window with standard margin and frame 12 | /// Returns the response from the window if it was shown 13 | pub fn show_center_popup_window( 14 | title: &str, 15 | ctx: &egui::Context, 16 | open: &mut bool, 17 | content: impl FnOnce(&mut egui::Ui) -> R, 18 | ) -> Option> { 19 | let win_resp = new_center_popup_window(title).open(open).show(ctx, |ui| { 20 | // NOTE: we are wrapping another frame here instead of setting 21 | // window.frame() because there is window.show_dyn overrides header 22 | // color with the frame's fill color when frame is not None, making it 23 | // impossible to set a different color for the window title bar. 24 | egui::Frame::new() 25 | .inner_margin(POPUP_MARGIN) 26 | .show(ui, |ui| content(ui)) 27 | }); 28 | win_resp.and_then(|response| response.inner) 29 | } 30 | -------------------------------------------------------------------------------- /crates/kiorg/src/utils/file_operations.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | /// Recursively copy a directory from src to dst 4 | pub fn copy_dir_recursively(src: &Path, dst: &Path) -> std::io::Result<()> { 5 | // Create the destination directory if it doesn't exist 6 | if !dst.exists() { 7 | std::fs::create_dir_all(dst)?; 8 | } 9 | 10 | // Iterate through the source directory entries 11 | for entry in std::fs::read_dir(src)? { 12 | let entry = entry?; 13 | let entry_path = entry.path(); 14 | let file_name = entry.file_name(); 15 | let dst_path = dst.join(file_name); 16 | 17 | if entry_path.is_dir() { 18 | // Recursively copy subdirectories 19 | copy_dir_recursively(&entry_path, &dst_path)?; 20 | } else { 21 | // Copy files 22 | std::fs::copy(&entry_path, &dst_path)?; 23 | } 24 | } 25 | 26 | Ok(()) 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use super::*; 32 | 33 | #[test] 34 | fn test_copy_dir_recursively() { 35 | // Test the helper function logic (without actual file operations) 36 | // This is a unit test for the function signature and basic error handling 37 | let src = Path::new("/nonexistent/src"); 38 | let dst = Path::new("/nonexistent/dst"); 39 | 40 | // Should return an error for non-existent paths 41 | let result = copy_dir_recursively(src, dst); 42 | assert!(result.is_err()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/popup/exit.rs: -------------------------------------------------------------------------------- 1 | use egui::Context; 2 | 3 | use super::utils::{ConfirmResult, show_confirm_popup}; 4 | use crate::app::Kiorg; 5 | use crate::ui::popup::PopupType; 6 | 7 | /// Handle exit confirmation 8 | pub fn confirm_exit(app: &mut Kiorg) { 9 | // Set shutdown_requested flag for graceful shutdown 10 | // The actual shutdown will be handled in the app's update loop 11 | app.shutdown_requested = true; 12 | app.show_popup = None; 13 | } 14 | 15 | /// Handle exit cancellation 16 | pub fn cancel_exit(app: &mut Kiorg) { 17 | app.show_popup = None; 18 | } 19 | 20 | /// Draw the exit confirmation popup 21 | pub fn draw(ctx: &Context, app: &mut Kiorg) { 22 | // Early return if not in exit mode 23 | if app.show_popup != Some(PopupType::Exit) { 24 | return; 25 | } 26 | 27 | let mut keep_open = true; 28 | 29 | let result = show_confirm_popup( 30 | ctx, 31 | "Exit Confirmation", 32 | &mut keep_open, 33 | |ui| { 34 | ui.vertical_centered(|ui| { 35 | ui.label("Are you sure you want to exit?"); 36 | }); 37 | }, 38 | "Exit (Enter)", 39 | "Cancel (Esc)", 40 | ); 41 | 42 | // Handle the result 43 | match result { 44 | ConfirmResult::Confirm => confirm_exit(app), 45 | ConfirmResult::Cancel => cancel_exit(app), 46 | ConfirmResult::None => { 47 | if !keep_open { 48 | cancel_exit(app); 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /memory-bank/ui_style_guide.md: -------------------------------------------------------------------------------- 1 | ## UI Style Guide 2 | 3 | ### Popups 4 | - All popups should use the `new_center_popup_window` utility function from `window_utils.rs` 5 | - Popups should be centered on the screen 6 | - Popups should have a close button in the top right corner or use keyboard shortcuts (Esc/q) to close 7 | - Confirmation popups (like delete and exit) should follow the pattern in `delete_popup.rs` 8 | - Help window should follow the pattern in `help_window.rs` 9 | - All popups should have consistent styling with the app's color scheme 10 | 11 | ### Input Bars 12 | - Search bars should follow the pattern in `search_bar.rs` 13 | - Input bars should have shadows similar to window popups for visual consistency 14 | - Close buttons should be placed in window titles rather than in search bars when possible 15 | 16 | ### Navigation 17 | - Double-clicking on entries should enter directories or open files 18 | - Terminal popup should be triggered by the 'T' shortcut using the egui_term crate 19 | - Context menu should support file operations: add, rename, delete, copy, cut, and paste 20 | - Context menu options should be enabled/disabled based on context (e.g., paste only enabled when clipboard has content) 21 | 22 | ### Layout 23 | - The right side preview panel should always be visible 24 | - Panels should be separated with consistent spacing 25 | - UI elements should have consistent padding and margins 26 | 27 | ### Testing 28 | - UI tests should be written in the @tests/ directory 29 | - Tests should cover right-click context menu functionality 30 | - Tests should verify that the right side preview panel is always visible 31 | -------------------------------------------------------------------------------- /crates/kiorg_plugin/README.md: -------------------------------------------------------------------------------- 1 | # kiorg-plugin 2 | 3 | A Rust crate providing the plugin framework for the kiorg file manager. 4 | 5 | ## Overview 6 | 7 | This crate defines the communication protocol and utilities for building 8 | plugins that integrate with kiorg. Plugins communicate with the main 9 | application using MessagePack-encoded messages over stdin/stdout. 10 | 11 | ## Usage 12 | 13 | Add this crate to your plugin's dependencies: 14 | 15 | ```toml 16 | [dependencies] 17 | kiorg_plugin = "*" 18 | ``` 19 | 20 | ### Basic Plugin Structure 21 | 22 | ```rust 23 | use kiorg_plugin::{ 24 | read_message, send_message, EngineCommand, 25 | PluginResponse, HelloMessage 26 | }; 27 | 28 | fn main() -> Result<(), Box> { 29 | loop { 30 | match read_message() { 31 | Ok(message) => { 32 | let response = match message.command { 33 | EngineCommand::Hello(_) => { 34 | PluginResponse::Hello(HelloMessage { 35 | version: "1.0.0".to_string(), 36 | }) 37 | } 38 | EngineCommand::Preview { path } => { 39 | // Generate preview content for the file 40 | let content = generate_preview(&path)?; 41 | PluginResponse::Preview { content } 42 | } 43 | }; 44 | send_message(&response)?; 45 | } 46 | Err(e) => { 47 | eprintln!("Error reading message: {}", e); 48 | break; 49 | } 50 | } 51 | } 52 | Ok(()) 53 | } 54 | ``` 55 | 56 | -------------------------------------------------------------------------------- /crates/kiorg/src/notifications/anchor.rs: -------------------------------------------------------------------------------- 1 | use egui::{Pos2, Vec2, pos2}; 2 | 3 | /// Anchor where to show toasts 4 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 5 | pub enum Anchor { 6 | /// Top right corner. 7 | TopRight, 8 | /// Top left corner. 9 | TopLeft, 10 | /// Bottom right corner. 11 | BottomRight, 12 | /// Bottom left corner 13 | BottomLeft, 14 | } 15 | 16 | impl Anchor { 17 | #[inline] 18 | pub(crate) const fn anim_side(&self) -> f32 { 19 | match self { 20 | Self::TopRight | Self::BottomRight => 1., 21 | Self::TopLeft | Self::BottomLeft => -1., 22 | } 23 | } 24 | } 25 | 26 | impl Anchor { 27 | pub(crate) fn screen_corner(&self, sc: Pos2, margin: Vec2) -> Pos2 { 28 | let mut out = match self { 29 | Self::TopRight => pos2(sc.x, 0.), 30 | Self::TopLeft => pos2(0., 0.), 31 | Self::BottomRight => sc, 32 | Self::BottomLeft => pos2(0., sc.y), 33 | }; 34 | self.apply_margin(&mut out, margin); 35 | out 36 | } 37 | 38 | pub(crate) fn apply_margin(&self, pos: &mut Pos2, margin: Vec2) { 39 | match self { 40 | Self::TopRight => { 41 | pos.x -= margin.x; 42 | pos.y += margin.y; 43 | } 44 | Self::TopLeft => { 45 | pos.x += margin.x; 46 | pos.y += margin.y; 47 | } 48 | Self::BottomRight => { 49 | pos.x -= margin.x; 50 | pos.y -= margin.y; 51 | } 52 | Self::BottomLeft => { 53 | pos.x += margin.x; 54 | pos.y -= margin.y; 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/egui_notify/anchor.rs: -------------------------------------------------------------------------------- 1 | use egui::{Pos2, Vec2, pos2}; 2 | 3 | /// Anchor where to show toasts 4 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 5 | pub enum Anchor { 6 | /// Top right corner. 7 | TopRight, 8 | /// Top left corner. 9 | TopLeft, 10 | /// Bottom right corner. 11 | BottomRight, 12 | /// Bottom left corner 13 | BottomLeft, 14 | } 15 | 16 | impl Anchor { 17 | #[inline] 18 | pub(crate) const fn anim_side(&self) -> f32 { 19 | match self { 20 | Self::TopRight | Self::BottomRight => 1., 21 | Self::TopLeft | Self::BottomLeft => -1., 22 | } 23 | } 24 | } 25 | 26 | impl Anchor { 27 | pub(crate) fn screen_corner(&self, sc: Pos2, margin: Vec2) -> Pos2 { 28 | let mut out = match self { 29 | Self::TopRight => pos2(sc.x, 0.), 30 | Self::TopLeft => pos2(0., 0.), 31 | Self::BottomRight => sc, 32 | Self::BottomLeft => pos2(0., sc.y), 33 | }; 34 | self.apply_margin(&mut out, margin); 35 | out 36 | } 37 | 38 | pub(crate) fn apply_margin(&self, pos: &mut Pos2, margin: Vec2) { 39 | match self { 40 | Self::TopRight => { 41 | pos.x -= margin.x; 42 | pos.y += margin.y; 43 | } 44 | Self::TopLeft => { 45 | pos.x += margin.x; 46 | pos.y += margin.y; 47 | } 48 | Self::BottomRight => { 49 | pos.x -= margin.x; 50 | pos.y -= margin.y; 51 | } 52 | Self::BottomLeft => { 53 | pos.x += margin.x; 54 | pos.y -= margin.y; 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /crates/kiorg/src/utils/icon.rs: -------------------------------------------------------------------------------- 1 | use egui::{ColorImage, IconData, TextureHandle, TextureOptions}; 2 | use image::RgbaImage; 3 | use std::sync::OnceLock; 4 | 5 | /// The icon bytes embedded directly in the binary 6 | pub static ICON_BYTES: &[u8] = include_bytes!("../../../../assets/icons/1024x1024@2x.png"); 7 | 8 | /// Lazily loaded app icon image 9 | static APP_ICON_IMAGE: OnceLock = OnceLock::new(); 10 | 11 | /// Get the app icon image, loading it if necessary 12 | fn get_app_icon_image() -> &'static RgbaImage { 13 | APP_ICON_IMAGE.get_or_init(|| { 14 | // Load the image from the embedded bytes only once 15 | image::load_from_memory(ICON_BYTES) 16 | .expect("Failed to load icon from embedded data") 17 | .into_rgba8() 18 | }) 19 | } 20 | 21 | /// Load the embedded icon data into an egui icon 22 | #[must_use] 23 | pub fn load_app_icon() -> IconData { 24 | let image = get_app_icon_image(); 25 | let width = image.width(); 26 | let height = image.height(); 27 | let rgba = image.clone().into_raw(); 28 | 29 | IconData { 30 | rgba, 31 | width: width as _, 32 | height: height as _, 33 | } 34 | } 35 | 36 | /// Load the app icon as a texture for display in UI 37 | pub fn load_app_icon_texture(ctx: &egui::Context) -> TextureHandle { 38 | let image = get_app_icon_image(); 39 | let width = image.width(); 40 | let height = image.height(); 41 | let size = [width as _, height as _]; 42 | let pixels = image.as_flat_samples(); 43 | 44 | // Create a texture from the image data 45 | ctx.load_texture( 46 | "app_icon", 47 | ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()), 48 | TextureOptions::default(), 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/popup/generic_message.rs: -------------------------------------------------------------------------------- 1 | // Generic message popup implementation 2 | use egui::Context; 3 | 4 | use super::PopupType; 5 | use super::window_utils::new_center_popup_window; 6 | use crate::app::Kiorg; 7 | use crate::ui::style::section_title_text; 8 | 9 | /// Show generic message popup with custom title and message 10 | pub fn show_generic_message_popup(ctx: &Context, app: &mut Kiorg) { 11 | // Check if the popup should be shown and extract title and message 12 | let (title, message) = match &app.show_popup { 13 | Some(PopupType::GenericMessage(title, msg)) => (title.clone(), msg.clone()), 14 | _ => return, 15 | }; 16 | 17 | let mut keep_open = true; // Use a temporary variable for the open state 18 | 19 | let response = new_center_popup_window(&title) 20 | .open(&mut keep_open) // Control window visibility 21 | .show(ctx, |ui| { 22 | ui.vertical_centered(|ui| { 23 | ui.add_space(10.0); 24 | 25 | // Display the message using section_title_text for consistent styling 26 | ui.label(message); 27 | 28 | ui.add_space(10.0); 29 | 30 | // Add a hint about closing the popup 31 | if ui 32 | .link(section_title_text("Press Esc or q to close", &app.colors)) 33 | .clicked() 34 | { 35 | app.show_popup = None; 36 | } 37 | ui.add_space(5.0); 38 | }); 39 | }); 40 | 41 | // Update the state based on window interaction 42 | if response.is_some() { 43 | if !keep_open { 44 | app.show_popup = None; 45 | } 46 | } else { 47 | app.show_popup = None; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /crates/kiorg/src/models/dir_entry.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::path::PathBuf; 3 | use std::time::SystemTime; 4 | 5 | #[derive(Clone, Debug, Serialize, Deserialize)] 6 | pub struct DirEntry { 7 | pub name: String, 8 | pub path: PathBuf, 9 | pub is_dir: bool, 10 | pub is_symlink: bool, 11 | pub modified: SystemTime, 12 | pub size: u64, 13 | pub formatted_modified: String, 14 | pub formatted_size: String, 15 | } 16 | 17 | impl DirEntry { 18 | pub fn accessibility_text(&self) -> String { 19 | let file_type = if self.is_dir { "folder" } else { "file" }; 20 | if self.is_symlink { 21 | format!( 22 | "{} {}, symbolic link, modified {}, size {}", 23 | file_type, self.name, self.formatted_modified, self.formatted_size 24 | ) 25 | } else { 26 | format!( 27 | "{} {}, modified {}, size {}", 28 | file_type, self.name, self.formatted_modified, self.formatted_size 29 | ) 30 | } 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod tests { 36 | use super::*; 37 | use std::time::UNIX_EPOCH; 38 | 39 | #[test] 40 | fn test_dir_entry_creation() { 41 | let entry = DirEntry { 42 | name: "test.txt".to_string(), 43 | path: PathBuf::from("/tmp/test.txt"), 44 | is_dir: false, 45 | is_symlink: false, 46 | modified: UNIX_EPOCH, 47 | size: 100, 48 | formatted_modified: "1970-01-01 00:00:00".to_string(), 49 | formatted_size: "100 B".to_string(), 50 | }; 51 | 52 | assert_eq!(entry.name, "test.txt"); 53 | assert_eq!(entry.path, PathBuf::from("/tmp/test.txt")); 54 | assert!(!entry.is_dir); 55 | assert_eq!(entry.size, 100); 56 | assert_eq!(entry.formatted_size, "100 B"); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/ui_terminal_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use tempfile::tempdir; 6 | use ui_test_helpers::{create_harness, shift_modifiers}; 7 | 8 | #[test] 9 | fn test_open_terminal_shortcut() { 10 | // Create a temporary directory for testing 11 | let temp_dir = tempdir().unwrap(); 12 | let mut harness = create_harness(&temp_dir); 13 | 14 | // Initially, terminal should not be shown 15 | assert_eq!( 16 | harness.state().show_popup, 17 | None, 18 | "No popup should be shown initially" 19 | ); 20 | 21 | // Press Shift+T to open terminal 22 | harness.key_press_modifiers(shift_modifiers(), Key::T); 23 | harness.step(); 24 | 25 | // On Windows, we should show a popup message instead of opening terminal 26 | #[cfg(target_os = "windows")] 27 | { 28 | use kiorg::ui::popup::PopupType; 29 | 30 | match &harness.state().show_popup { 31 | Some(popup) => { 32 | // Verify it's a generic message popup (not terminal or other types) 33 | match popup { 34 | PopupType::GenericMessage { .. } => { 35 | // This is the expected popup type for Windows 36 | } 37 | _ => panic!("Expected GenericMessage popup on Windows, got: {:?}", popup), 38 | } 39 | } 40 | None => panic!("A popup message should be shown on Windows"), 41 | } 42 | assert!( 43 | harness.state().terminal_ctx.is_none(), 44 | "Terminal should not be opened on Windows" 45 | ); 46 | } 47 | 48 | // On non-Windows platforms, terminal should be opened 49 | #[cfg(not(target_os = "windows"))] 50 | { 51 | assert!( 52 | harness.state().terminal_ctx.is_some(), 53 | "Terminal should be open on non-Windows platforms" 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/initial_path_test.rs: -------------------------------------------------------------------------------- 1 | use egui::Context; 2 | use kiorg::Kiorg; 3 | use tempfile::tempdir; 4 | 5 | #[test] 6 | fn test_fallback_to_current_dir_when_saved_path_nonexistent() { 7 | // Create a temporary directory for testing 8 | let temp_dir = tempdir().unwrap(); 9 | let test_dir_path = temp_dir.path().to_path_buf(); 10 | 11 | // Create a config directory 12 | let config_dir = test_dir_path.join("config"); 13 | std::fs::create_dir_all(&config_dir).unwrap(); 14 | 15 | // Create a state.json file with a non-existent path 16 | let state_json = r#"{ 17 | "tab_manager": { 18 | "tab_states": [ 19 | { 20 | "current_path": "/path/that/does/not/exist" 21 | } 22 | ], 23 | "current_tab_index": 0, 24 | "sort_column": "Name", 25 | "sort_order": "Ascending" 26 | } 27 | }"#; 28 | std::fs::write(config_dir.join("state.json"), state_json).unwrap(); 29 | 30 | // Create a new egui context 31 | let ctx = Context::default(); 32 | let cc = eframe::CreationContext::_new_kittest(ctx); 33 | 34 | // Create the app with the test config directory override 35 | // We don't provide an initial directory, so it should try to load from state.json 36 | let app = Kiorg::new_with_config_dir(&cc, None, Some(config_dir)) 37 | .expect("Failed to create Kiorg app"); 38 | 39 | // Check that the current path is not the non-existent path from state.json 40 | let current_path = app.tab_manager.current_tab_ref().current_path.clone(); 41 | assert_ne!( 42 | current_path.to_str().unwrap(), 43 | "/path/that/does/not/exist", 44 | "App should not use non-existent path from state.json" 45 | ); 46 | 47 | // The app should have fallen back to the current directory 48 | let expected_path = dirs::home_dir().unwrap(); 49 | assert_eq!( 50 | current_path, expected_path, 51 | "App should fall back to current directory when saved path doesn't exist" 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/popup/about.rs: -------------------------------------------------------------------------------- 1 | use egui::{Context, Image, RichText}; 2 | 3 | use super::PopupType; 4 | use super::window_utils::show_center_popup_window; 5 | use crate::app::Kiorg; 6 | use crate::utils::icon; 7 | 8 | /// Show about popup with application information 9 | pub fn show_about_popup(ctx: &Context, app: &mut Kiorg) { 10 | // Check if the popup should be shown based on the show_popup field 11 | if app.show_popup != Some(PopupType::About) { 12 | return; 13 | } 14 | 15 | let mut keep_open = true; // Use a temporary variable for the open state 16 | 17 | let response = show_center_popup_window("About", ctx, &mut keep_open, |ui| { 18 | ui.vertical_centered(|ui| { 19 | // Load and display the app icon 20 | let texture = icon::load_app_icon_texture(ctx); 21 | 22 | // Display the image with a fixed size 23 | ui.add(Image::new(&texture).max_width(128.0)); 24 | 25 | ui.label(format!("Kiorg v{}", env!("CARGO_PKG_VERSION"))); 26 | 27 | // Repository URL as a clickable link 28 | let repo_url = env!("CARGO_PKG_REPOSITORY"); 29 | if ui 30 | .link(RichText::new(repo_url).color(app.colors.link_text)) 31 | .clicked() 32 | && let Err(e) = open::that(repo_url) 33 | { 34 | // Call notify_error wrapper 35 | app.notify_error(format!("Failed to open URL: {e}")); 36 | } 37 | ui.add_space(10.0); 38 | 39 | // Add a hint about closing the popup 40 | if ui 41 | .link(RichText::new("Press Esc or q to close").color(app.colors.fg_light)) 42 | .clicked() 43 | { 44 | app.show_popup = None; 45 | } 46 | }); 47 | }); 48 | 49 | // Update the state based on window interaction 50 | if response.is_some() { 51 | if !keep_open { 52 | app.show_popup = None; 53 | } 54 | } else { 55 | app.show_popup = None; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/popup/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::ui::update::Release; 4 | 5 | pub mod about; 6 | pub mod action_history; 7 | pub mod add_entry; 8 | pub mod bookmark; 9 | pub mod delete; 10 | pub mod exit; 11 | pub mod file_drop; 12 | pub mod generic_message; 13 | pub mod open_with; 14 | pub mod plugin; 15 | pub mod preview; 16 | pub mod rename; 17 | pub mod sort_toggle; 18 | pub mod teleport; 19 | pub mod theme; 20 | pub mod utils; 21 | #[cfg(target_os = "macos")] 22 | pub mod volumes; 23 | pub mod window_utils; 24 | #[cfg(target_os = "windows")] 25 | pub mod windows_drives; 26 | 27 | /// Popup types that can be shown in the application 28 | #[derive(Debug, PartialEq, Eq)] 29 | pub enum PopupType { 30 | About, 31 | Help, 32 | Exit, 33 | GenericMessage(String, String), // Title and message for generic popup 34 | Delete(crate::ui::popup::delete::DeleteConfirmState, Vec), 35 | DeleteProgress(crate::ui::popup::delete::DeleteProgressData), 36 | Rename(String), // New name for the file/directory being renamed 37 | OpenWith(String), // Command to use when opening a file with a custom command 38 | AddEntry(String), // Name for the new file/directory being added 39 | Bookmarks(usize), // Selected index in the bookmarks list 40 | #[cfg(target_os = "windows")] 41 | WindowsDrives(usize), // Selected index in the drives list (Windows only) 42 | #[cfg(target_os = "macos")] 43 | Volumes(usize), // Selected index in the volumes list (macOS only) 44 | Preview, // Show file preview in a popup window 45 | Themes(String), // Selected theme key in the themes list 46 | Plugins, // Show plugins list 47 | FileDrop(Vec), // List of dropped files 48 | Teleport(crate::ui::popup::teleport::TeleportState), // Teleport through visit history 49 | UpdateConfirm(Release), // Show update confirmation with version info 50 | UpdateProgress(crate::ui::update::UpdateProgressData), // Show update progress during download 51 | UpdateRestart, // Show restart confirmation with version info 52 | SortToggle, // Show sort toggle popup for column sorting 53 | ActionHistory, // Show action history with rollback options 54 | } 55 | -------------------------------------------------------------------------------- /memory-bank/progress.md: -------------------------------------------------------------------------------- 1 | ## Progress 2 | 3 | ### What works 4 | 5 | * Basic file navigation (j, k, h, l, Enter, gg, G) 6 | * File opening (o/Enter) 7 | * File deletion (D) 8 | * File renaming (r) 9 | * File selection (space) 10 | * File copying (y) 11 | * File cutting (x) 12 | * File pasting (p) 13 | * Application exiting (q) 14 | * Bookmark management (b, B) 15 | * Tab creation and switching (t, 1, 2, 3, etc.) 16 | * Help window (?) 17 | * Configurable color schemes 18 | * Column sort order persistence between sessions (fully implemented and tested) 19 | * Search filter with robust visual highlighting and support for large directories, Unicode, and long filenames 20 | * Real-time filtering as you type 21 | * Orange highlighting of matching text 22 | * Search state persists after Enter 23 | * Clear filter with Esc 24 | * Add file/directory (a) 25 | * Right click context menu with operations: 26 | * Add new file/directory 27 | * Rename selected file/directory 28 | * Delete selected file/directory 29 | * Copy selected file/directory 30 | * Cut selected file/directory 31 | * Paste copied/cut file/directory 32 | * Context-aware enabling/disabling of options 33 | * Tab selection preservation when switching between tabs at runtime 34 | * SVG preview using the resvg crate 35 | * Image previews using egui's Image widget with direct URI source paths 36 | * Zip file preview showing contained files and folders 37 | * PDF preview with metadata display and rendered first page 38 | * EPUB preview with metadata display and cover image 39 | * Configurable keyboard shortcuts through TOML config files 40 | * 'g' namespace key similar to Vim for special shortcut combinations 41 | * Soft/hard link files display with dedicated icons 42 | * Directory history navigation with Ctrl+O (back) and Ctrl+I (forward) within each tab 43 | * Async delete operations with progress dialog to prevent UI blocking 44 | * Benchmarking infrastructure for performance monitoring 45 | * Image zoom functionality in preview popups with mouse wheel and keyboard controls 46 | * Volume viewer for macOS to browse mounted volumes and drives 47 | * Windows drives viewer to access different drive letters on Windows systems 48 | 49 | ### What's left to build 50 | 51 | * render PDF preview using pdfium_render or pathfinder_rasterize 52 | * see 53 | * Moving up jumps when reached the first page -------------------------------------------------------------------------------- /memory-bank/systemPatterns.md: -------------------------------------------------------------------------------- 1 | ## System Patterns 2 | 3 | ### System architecture 4 | The application follows a modular architecture, with separate modules for UI, models, configuration, and utilities. The UI is built using the egui framework and is composed of several panels: left panel (bookmarks), center panel (file list), right panel (preview). 5 | 6 | The configuration system has been expanded to include user preferences such as column sort order, which are persisted between application sessions using TOML files. 7 | 8 | ### Key technical decisions 9 | * Using Rust for performance, safety, and cross-platform compatibility. 10 | * Using egui for rapid UI development. 11 | * Using TOML for configuration files. 12 | * Using serde for serialization and deserialization of configuration data. 13 | * Implementing user preference persistence for improved user experience. 14 | * Implementing async operations for long-running tasks to prevent UI blocking. 15 | * Adding performance benchmarking infrastructure for optimization guidance. 16 | 17 | ### Design patterns in use 18 | * Composition over inheritance. 19 | * Modular design. 20 | 21 | ### Component relationships 22 | * The `app.rs` module is the main entry point and orchestrates the other modules. 23 | * The `ui` module contains the UI components. 24 | * The `models` module defines the data structures. 25 | * The `config` module handles the application configuration and user preferences persistence. 26 | * The `utils` module provides utility functions. 27 | * The `Tab` model now interacts with the `Config` module to initialize with persisted sort preferences. 28 | * The center panel UI interacts with the `Config` module to save sort preferences when they change. 29 | * Platform-specific popup modules (`volumes.rs`, `windows_drives.rs`) handle OS-specific file system browsing. 30 | * The preview system includes dedicated handlers for different content types, including zoomable image previews. 31 | 32 | ### Critical implementation paths 33 | * File navigation: `src/ui/center_panel.rs` handles the display of files and folders, and responds to keyboard input for navigation. 34 | * Main app entry point: `src/app.rs`. 35 | * Bookmark management: `src/ui/left_panel.rs` handles the display and management of bookmarks. 36 | * Configuration management: `src/config/mod.rs` handles loading and saving application configuration. 37 | * Sort order persistence: Column sorting in `src/ui/file_list.rs` triggers configuration updates through `src/ui/center_panel.rs`. 38 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/popup/sort_toggle.rs: -------------------------------------------------------------------------------- 1 | //! Sort toggle popup module for toggling sort order of file manager columns 2 | 3 | use crate::app::Kiorg; 4 | use crate::models::tab::SortColumn; 5 | use crate::ui::popup::PopupType; 6 | use crate::ui::popup::window_utils::new_center_popup_window; 7 | use egui::{Align2, Color32, Key, RichText}; 8 | 9 | /// Show the sort toggle popup 10 | pub fn show_sort_toggle_popup(app: &mut Kiorg, ctx: &egui::Context) { 11 | // Check if the popup should be shown based on the show_popup field 12 | if app.show_popup != Some(PopupType::SortToggle) { 13 | return; 14 | } 15 | 16 | let mut keep_open = true; // Use a temporary variable for the open state 17 | 18 | let response = new_center_popup_window("Sort Toggle") 19 | .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) 20 | .open(&mut keep_open) // Control window visibility 21 | .show(ctx, |ui| { 22 | ui.add_space(10.0); 23 | ui.vertical_centered(|ui| { 24 | // Simple shortcut hints displayed horizontally 25 | ui.horizontal(|ui| { 26 | ui.add_space(10.0); 27 | ui.label(RichText::new("[n]").color(Color32::LIGHT_BLUE).strong()); 28 | ui.label("Name"); 29 | 30 | ui.add_space(20.0); 31 | 32 | ui.label(RichText::new("[s]").color(Color32::LIGHT_BLUE).strong()); 33 | ui.label("Size"); 34 | 35 | ui.add_space(20.0); 36 | 37 | ui.label(RichText::new("[m]").color(Color32::LIGHT_BLUE).strong()); 38 | ui.label("Modified"); 39 | ui.add_space(10.0); 40 | }); 41 | }); 42 | ui.add_space(10.0); 43 | }); 44 | 45 | // Update the state based on window interaction 46 | if response.is_some() { 47 | if !keep_open { 48 | app.show_popup = None; 49 | } 50 | } else { 51 | app.show_popup = None; 52 | } 53 | } 54 | 55 | /// Handle key input when the sort toggle popup is active 56 | pub fn handle_sort_toggle_key(app: &mut Kiorg, key: Key) { 57 | match key { 58 | Key::N => { 59 | app.tab_manager.toggle_sort(SortColumn::Name); 60 | } 61 | Key::S => { 62 | app.tab_manager.toggle_sort(SortColumn::Size); 63 | } 64 | Key::M => { 65 | app.tab_manager.toggle_sort(SortColumn::Modified); 66 | } 67 | _ => {} 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/ui_help_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use kiorg::ui::popup::PopupType; 6 | use tempfile::tempdir; 7 | use ui_test_helpers::{TestHarnessBuilder, shift_modifiers}; 8 | 9 | #[test] 10 | fn test_help_menu_close_behavior() { 11 | let temp_dir = tempdir().unwrap(); 12 | 13 | // Create the test harness with default config (only built-in themes) 14 | let mut harness = TestHarnessBuilder::new() 15 | .with_temp_dir(&temp_dir) 16 | .with_window_size(egui::Vec2::new(800.0, 800.0)) 17 | .build(); 18 | 19 | // Open help menu with shift+? 20 | { 21 | harness.key_press_modifiers(shift_modifiers(), Key::Questionmark); 22 | harness.step(); 23 | } 24 | assert_eq!( 25 | harness.state().show_popup, 26 | Some(PopupType::Help), 27 | "Help menu should be open" 28 | ); 29 | #[cfg(feature = "snapshot")] 30 | { 31 | // multiple steps to ensure the menu animation completes 32 | harness.step(); 33 | harness.step(); 34 | harness.snapshot("help_menu"); 35 | } 36 | 37 | // Test closing with Escape 38 | harness.key_press(Key::Escape); 39 | harness.step(); 40 | assert_eq!( 41 | harness.state().show_popup, 42 | None, 43 | "Help menu should close with Escape" 44 | ); 45 | 46 | // Reopen help menu 47 | { 48 | harness.key_press_modifiers(shift_modifiers(), Key::Questionmark); 49 | harness.step(); 50 | } 51 | 52 | // Test closing with Q 53 | harness.key_press(Key::Q); 54 | harness.step(); 55 | assert_eq!( 56 | harness.state().show_popup, 57 | None, 58 | "Help menu should close with Q" 59 | ); 60 | 61 | // Reopen help menu 62 | { 63 | harness.key_press_modifiers(shift_modifiers(), Key::Questionmark); 64 | harness.step(); 65 | } 66 | 67 | // Test closing with Enter 68 | harness.key_press(Key::Enter); 69 | harness.step(); 70 | assert_eq!( 71 | harness.state().show_popup, 72 | None, 73 | "Help menu should close with Enter" 74 | ); 75 | 76 | // Reopen help menu 77 | { 78 | harness.key_press_modifiers(shift_modifiers(), Key::Questionmark); 79 | harness.step(); 80 | } 81 | 82 | // Test closing with ? (Questionmark) 83 | harness.key_press(Key::Questionmark); 84 | harness.step(); 85 | assert_eq!( 86 | harness.state().show_popup, 87 | None, 88 | "Help menu should close with ?" 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/popup/open_with.rs: -------------------------------------------------------------------------------- 1 | use crate::app::Kiorg; 2 | use crate::ui::popup::PopupType; 3 | use egui::{Frame, TextEdit}; 4 | 5 | use super::window_utils::new_center_popup_window; 6 | 7 | /// Draw the open with popup dialog 8 | pub fn draw(ctx: &egui::Context, app: &mut Kiorg) { 9 | // Early return if not in open with mode 10 | if let Some(PopupType::OpenWith(command)) = &mut app.show_popup { 11 | let mut keep_open: bool = true; 12 | 13 | // Create a centered popup window 14 | new_center_popup_window("Open with") 15 | .open(&mut keep_open) 16 | .show(ctx, |ui| { 17 | // Create a frame with styling similar to other popups 18 | Frame::default() 19 | .fill(app.colors.bg_light) 20 | .inner_margin(5.0) 21 | .show(ui, |ui| { 22 | ui.set_max_width(400.0); // Limit width 23 | 24 | // Horizontal layout for input and close button 25 | ui.horizontal(|ui| { 26 | // Text input field 27 | let text_edit = TextEdit::singleline(command) 28 | .hint_text("Enter command to open...") 29 | .desired_width(f32::INFINITY) // Take available width 30 | .frame(false); // No frame, like search bar 31 | 32 | let response = ui.add(text_edit); 33 | 34 | // Always request focus when the popup is shown 35 | response.request_focus(); 36 | }); 37 | }); 38 | }); 39 | 40 | if !keep_open { 41 | close_popup(app); 42 | } 43 | } 44 | } 45 | 46 | /// Helper function to handle open with confirmation 47 | pub fn confirm_open_with(app: &mut Kiorg, command: String) { 48 | if command.is_empty() { 49 | app.notify_error("Cannot open: No command provided"); 50 | return; 51 | } 52 | 53 | // Get the path and command before calling other functions to avoid borrow issues 54 | let path_to_open = { 55 | let tab = app.tab_manager.current_tab_ref(); 56 | tab.selected_entry().map(|entry| entry.path.clone()) 57 | }; 58 | 59 | // Only open if we have a valid path 60 | if let Some(path) = path_to_open { 61 | app.open_file_with_command(path, command); 62 | } 63 | 64 | close_popup(app); 65 | } 66 | 67 | /// Helper function to handle open with cancellation 68 | pub fn close_popup(app: &mut Kiorg) { 69 | app.show_popup = None; 70 | } 71 | -------------------------------------------------------------------------------- /memory-bank/projectbrief.md: -------------------------------------------------------------------------------- 1 | Kiorg is a ultra fast, light weight cross platform file management app with a Vim inspired key binding. 2 | 3 | It is built using Rust with the egui framework. 4 | 5 | ## Keyboard shortcuts to relevant features 6 | * `j` to move down to the next entry 7 | * `k` to move up to the previous entry 8 | * `h` to navigate to the parent directory 9 | * `l` or Enter to navigate into a selected folder 10 | * `o/Enter` to open the selected file with external app 11 | * `gg` to go to the first entry 12 | * `G` to go to the last entry 13 | * `D` to delete the selected file or folder with a confirmation prompt 14 | * `r` to rename a file or directory 15 | * space to select an entry 16 | * `y` to copy an entry 17 | * `x` to cut an entry 18 | * `p` to paste an entry 19 | * `/` to enter search filter mode 20 | - Type keywords to filter the list in real-time 21 | - Press Enter to confirm the filter and interact with the results 22 | - Press Esc to cancel or clear the filter 23 | * `q` to exit the application with a confirmation popup that confirms the exit through enter 24 | - All popups in the app can be closed by pressing `q`, including the exit confirmation popup 25 | * `b` to add/remove the current entry to bookmark 26 | * `B` to toggle the bookmark popup 27 | - The bookmark menu should be centered to the screen, when it's active, it consumes all the input. 28 | * User should be able to navigate within the bookmark menu using keyboards. 29 | * `q` and `Esc` to exit the bookmark popup 30 | * `d` to delete a bookmark 31 | - Selecting a bookmark will jump directly to the bookmarked directory 32 | - Only allow bookmarking directory, not files 33 | - Bookmarks will be saved to `.config/kiorg/bookmarks.txt` 34 | * `t` to create a new tab in the file browser 35 | - users can use number key `1`, `2`, `3`, etc to switch between tabs 36 | - tab numbers are displayed in right side of the top nav banner, with the current tab highlighted 37 | * `?` to toggle help window that displays all the shortcuts in a popup window 38 | * `a` to add file/directory 39 | 40 | ## Visual design 41 | 42 | * Clean layout with compact spacing and alignment 43 | * The application displays files and folders in the current directory with the following information: 44 | * File/folder names 45 | * Modified dates 46 | * File sizes in human readable format (for files only) 47 | * Path truncation for long paths with "..." in the middle 48 | * Bookmarked entries in the left and middle panel should be highlighted with bookmark icons 49 | * The application uses icons to distinguish between files (📄) and folders (📁) 50 | * When file is being opened, flash the relevant file entry 51 | 52 | ## Other features 53 | 54 | * Supprot configurable color schemes through toml config files. Provides a builtin default them that looks like the editor color scheme Sonokai. -------------------------------------------------------------------------------- /tests/ui_toggle_hidden_files_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use tempfile::tempdir; 6 | use ui_test_helpers::{create_harness, create_test_files, ctrl_modifiers}; 7 | 8 | #[cfg(windows)] 9 | fn set_hidden_attribute_on_paths(paths: &[std::path::PathBuf]) { 10 | use std::os::windows::ffi::OsStrExt; 11 | use windows_sys::Win32::Storage::FileSystem::{FILE_ATTRIBUTE_HIDDEN, SetFileAttributesW}; 12 | 13 | for path in paths { 14 | let wide_path: Vec = path 15 | .as_os_str() 16 | .encode_wide() 17 | .chain(std::iter::once(0)) 18 | .collect(); 19 | let result = unsafe { SetFileAttributesW(wide_path.as_ptr(), FILE_ATTRIBUTE_HIDDEN) }; 20 | if result == 0 { 21 | panic!("Failed to set hidden attribute on {:?}", path); 22 | } 23 | } 24 | } 25 | 26 | #[test] 27 | fn test_ctrl_h_toggle_hidden_files() { 28 | // Create a temporary directory with visible and hidden files/dirs 29 | let temp_dir = tempdir().unwrap(); 30 | #[cfg(not(windows))] 31 | { 32 | create_test_files(&[ 33 | temp_dir.path().join("visible_file.txt"), 34 | temp_dir.path().join(".hidden_file.txt"), 35 | temp_dir.path().join(".hidden_dir"), 36 | ]); 37 | } 38 | #[cfg(windows)] 39 | { 40 | let paths_to_hide = [ 41 | temp_dir.path().join("hidden_file.txt"), 42 | temp_dir.path().join("hidden_dir"), 43 | ]; 44 | create_test_files(&[temp_dir.path().join("visible_file.txt")]); 45 | create_test_files(&paths_to_hide); 46 | 47 | set_hidden_attribute_on_paths(&paths_to_hide); 48 | } 49 | 50 | let mut harness = create_harness(&temp_dir); 51 | 52 | // Initial state check 53 | { 54 | let tab = harness.state().tab_manager.current_tab_ref(); 55 | assert!(!harness.state().tab_manager.show_hidden); 56 | assert_eq!(tab.entries.len(), 1); 57 | assert_eq!(tab.entries[0].name, "visible_file.txt"); 58 | } 59 | 60 | // Press Ctrl+H to show hidden files 61 | harness.key_press_modifiers(ctrl_modifiers(), Key::H); 62 | harness.step(); 63 | 64 | // Verify hidden files are shown 65 | { 66 | let tab = harness.state().tab_manager.current_tab_ref(); 67 | assert!(harness.state().tab_manager.show_hidden); 68 | assert_eq!(tab.entries.len(), 3); 69 | } 70 | 71 | // Press Ctrl+H again to hide the files 72 | harness.key_press_modifiers(ctrl_modifiers(), Key::H); 73 | harness.step(); 74 | 75 | // Verify hidden files are hidden again 76 | { 77 | let tab = harness.state().tab_manager.current_tab_ref(); 78 | assert!(!harness.state().tab_manager.show_hidden); 79 | assert_eq!(tab.entries.len(), 1); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kiorg 2 | [![Releases](https://img.shields.io/badge/-releases-blue)](https://github.com/houqp/kiorg/releases) 3 | [![discord](https://dcbadge.limes.pink/api/server/https://discord.gg/TdTb2CHfpr?style=flat&compact=true)](https://discord.gg/TdTb2CHfpr) 4 | 5 |

6 | Kiorg Logo 7 |

8 | 9 | Kiorg is a performance focused cross-platform file manager with Vim-inspired key 10 | bindings. It is built using the [egui](https://www.egui.rs/#demo) framework. 11 | 12 | ## Key Features 13 | 14 | * Lightingly fast rendering and navigation 15 | * Multi-tab support 16 | * Vim-inspired keyboard shortcuts 17 | * Built-in zoxide like fuzzy directory teleport 18 | * Content preview for various file formats including code syntax highlight, image, pdf, epub, etc. 19 | * Customizable shortcuts and color themes through TOML config files 20 | * Cross-platform support (Linux, macOS, Windows) 21 | * Bookmarks for quick access to frequently used directories 22 | * Single self-contained binary with battery included 23 | * Builtin terminal emulator 24 | * App state persistence 25 | * Language agnostic plugin system 26 | 27 | ## Screenshots 28 | 29 |

30 | Help Menu 31 |
32 | Built-in help menu with keyboard shortcuts 33 |

34 | 35 |

36 | Theme Selection 37 |
38 | Customizable color themes 39 |

40 | 41 | ## Installation 42 | 43 | Pre-built binaries for all platforms are available on the [releases page](https://github.com/houqp/kiorg/releases). 44 | 45 | Alternatively, you can build it from source using cargo: 46 | 47 | ```bash 48 | git clone --recurse-submodules https://github.com/houqp/kiorg.git && cargo install --locked --path ./kiorg 49 | ``` 50 | 51 | ## Configuration 52 | 53 | Kiorg uses TOML configuration files stored in the user's config directory: 54 | 55 | * Linux: `~/.config/kiorg/` 56 | * macOS: `~/.config/kiorg/` (if it exists) or `~/Library/Application Support/kiorg/` 57 | * Windows: `%APPDATA%\kiorg\` 58 | 59 | ### Sample Configuration 60 | 61 | ```toml 62 | # Sort preference configuration (optional) 63 | [sort_preference] 64 | column = "Name" # Sort column: "Name", "Modified", "Size", or "None" 65 | order = "Ascending" # Sort order: "Ascending" or "Descending" 66 | 67 | [layout] 68 | preview = 0.5 # Increase preview default width ratio to 50% 69 | 70 | # Override default shortcuts (optional) 71 | [shortcuts] 72 | MoveDown = [ 73 | { key = "j" }, 74 | { key = "down" } 75 | ] 76 | MoveUp = [ 77 | { key = "k" }, 78 | { key = "up" } 79 | ] 80 | DeleteEntry = [ 81 | { key = "d" } 82 | ] 83 | ActivateSearch = [ 84 | { key = "/" }, 85 | { key = "f", ctrl = true } 86 | ] 87 | ``` 88 | -------------------------------------------------------------------------------- /crates/kiorg_plugin/examples/demo_plugin/src/main.rs: -------------------------------------------------------------------------------- 1 | //! Demo plugin demonstrating the simplified kiorg plugin system 2 | //! 3 | //! This plugin demonstrates the basic Hello/Preview protocol, always 4 | //! returning "hello world" for preview requests. 5 | 6 | use kiorg_plugin::{ 7 | PluginCapabilities, PluginHandler, PluginMetadata, PluginResponse, PreviewCapability, 8 | }; 9 | 10 | const ICON_BYTES: &[u8] = include_bytes!("../../../../../assets/icons/1024x1024@2x.png"); 11 | 12 | struct DemoPlugin { 13 | metadata: PluginMetadata, 14 | } 15 | 16 | impl PluginHandler for DemoPlugin { 17 | fn on_preview(&mut self, path: &str) -> PluginResponse { 18 | // Return preview content that includes the file path 19 | PluginResponse::Preview { 20 | components: vec![ 21 | kiorg_plugin::Component::Title(kiorg_plugin::TitleComponent { 22 | text: "Demo Plugin Preview".to_string(), 23 | }), 24 | kiorg_plugin::Component::Text(kiorg_plugin::TextComponent { 25 | text: format!("Hello from demo plugin!\n\nFile: {}", path), 26 | }), 27 | kiorg_plugin::Component::Image(kiorg_plugin::ImageComponent { 28 | source: kiorg_plugin::ImageSource::Bytes { 29 | format: kiorg_plugin::ImageFormat::Png, 30 | data: ICON_BYTES.to_vec(), 31 | }, 32 | }), 33 | kiorg_plugin::Component::Table(kiorg_plugin::TableComponent { 34 | headers: Some(vec!["Property".to_string(), "Value".to_string()]), 35 | rows: vec![ 36 | vec![ 37 | "Plugin Name".to_string(), 38 | env!("CARGO_PKG_NAME").to_string(), 39 | ], 40 | vec!["Plugin Version".to_string(), self.metadata.version.clone()], 41 | ], 42 | }), 43 | ], 44 | } 45 | } 46 | 47 | fn metadata(&self) -> PluginMetadata { 48 | self.metadata.clone() 49 | } 50 | } 51 | 52 | fn main() -> Result<(), Box> { 53 | DemoPlugin { 54 | metadata: PluginMetadata { 55 | name: env!("CARGO_PKG_NAME").to_string(), 56 | version: env!("CARGO_PKG_VERSION").to_string(), 57 | description: 58 | "A demo plugin demonstrating kiorg plugin capabilities for preview rendering" 59 | .to_string(), 60 | homepage: None, 61 | capabilities: PluginCapabilities { 62 | preview: Some(PreviewCapability { 63 | file_pattern: r"^kiorg$".to_string(), // Match files named "kiorg" 64 | }), 65 | }, 66 | }, 67 | } 68 | .run(); 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /tests/ui_unmark_cut_file_test.rs: -------------------------------------------------------------------------------- 1 | use egui::Key; 2 | 3 | #[path = "mod/ui_test_helpers.rs"] 4 | mod ui_test_helpers; 5 | use ui_test_helpers::{create_harness, create_test_files}; 6 | 7 | #[test] 8 | fn test_unmark_cut_file() { 9 | // Create a temporary directory with test files 10 | let temp_dir = tempfile::tempdir().unwrap(); 11 | 12 | // Create test files and directories 13 | let test_files = create_test_files(&[ 14 | temp_dir.path().join("dir1"), 15 | temp_dir.path().join("dir2"), 16 | temp_dir.path().join("test1.txt"), 17 | temp_dir.path().join("test2.txt"), 18 | ]); 19 | 20 | let mut harness = create_harness(&temp_dir); 21 | 22 | // Move down twice to select test1.txt (after dir1 and dir2) 23 | harness.key_press(Key::J); 24 | harness.step(); 25 | harness.key_press(Key::J); 26 | harness.step(); 27 | 28 | // Mark the file 29 | harness.key_press(Key::Space); 30 | harness.step(); 31 | 32 | // Verify the file is marked 33 | { 34 | let app = harness.state(); 35 | let tab = app.tab_manager.current_tab_ref(); 36 | assert!( 37 | tab.marked_entries.contains(&test_files[2]), 38 | "test1.txt should be marked" 39 | ); 40 | } 41 | 42 | // Cut the marked file 43 | harness.key_press(Key::X); 44 | harness.step(); 45 | 46 | // Verify the file is in the clipboard as a cut operation 47 | { 48 | let app = harness.state(); 49 | assert!(app.clipboard.is_some(), "Clipboard should contain cut file"); 50 | if let Some(kiorg::app::Clipboard::Cut(paths)) = &app.clipboard { 51 | assert_eq!(paths.len(), 1, "Clipboard should contain exactly one file"); 52 | assert_eq!( 53 | paths[0], test_files[2], 54 | "Clipboard should contain test1.txt" 55 | ); 56 | } else { 57 | panic!("Clipboard should contain a Cut operation"); 58 | } 59 | } 60 | 61 | // Unmark the file 62 | harness.key_press(Key::Space); 63 | harness.step(); 64 | 65 | // Verify the file is unmarked and removed from the clipboard 66 | { 67 | let app = harness.state(); 68 | let tab = app.tab_manager.current_tab_ref(); 69 | 70 | // File should be unmarked 71 | assert!( 72 | !tab.marked_entries.contains(&test_files[2]), 73 | "test1.txt should be unmarked" 74 | ); 75 | 76 | // Clipboard should be empty or the file should be removed from it 77 | match &app.clipboard { 78 | Some(kiorg::app::Clipboard::Cut(paths) | kiorg::app::Clipboard::Copy(paths)) => { 79 | assert!( 80 | !paths.contains(&test_files[2]), 81 | "test1.txt should be removed from clipboard" 82 | ); 83 | } 84 | None => {} 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /crates/kiorg/src/open_wrap.rs: -------------------------------------------------------------------------------- 1 | #[cfg(not(any(test, feature = "testing")))] 2 | pub use open::{that as open_that, with as open_with}; 3 | 4 | #[cfg(any(test, feature = "testing"))] 5 | pub mod mock_open { 6 | use std::sync::{Mutex, MutexGuard, OnceLock}; 7 | 8 | static OPEN_WITH_CALLS: OnceLock>> = OnceLock::new(); 9 | static OPEN_THAT_CALLS: OnceLock>> = OnceLock::new(); 10 | static TEST_SERIALIZATION_LOCK: OnceLock> = OnceLock::new(); 11 | 12 | fn get_open_with_calls_storage() -> &'static Mutex> { 13 | OPEN_WITH_CALLS.get_or_init(|| Mutex::new(Vec::new())) 14 | } 15 | 16 | fn get_open_that_calls_storage() -> &'static Mutex> { 17 | OPEN_THAT_CALLS.get_or_init(|| Mutex::new(Vec::new())) 18 | } 19 | 20 | fn get_test_serialization_lock() -> &'static Mutex<()> { 21 | TEST_SERIALIZATION_LOCK.get_or_init(|| Mutex::new(())) 22 | } 23 | 24 | /// Acquires the test serialization lock to ensure tests don't interfere with each other. 25 | /// Returns a guard that holds the lock until dropped. 26 | pub fn acquire_open_test_lock() -> MutexGuard<'static, ()> { 27 | get_test_serialization_lock().lock().unwrap() 28 | } 29 | 30 | #[derive(Debug, Clone, PartialEq)] 31 | pub struct OpenCall { 32 | pub path: std::ffi::OsString, 33 | pub app: Option, 34 | } 35 | 36 | pub fn get_open_with_calls() -> Vec { 37 | let calls = get_open_with_calls_storage().lock().unwrap(); 38 | calls.clone() 39 | } 40 | 41 | pub fn get_open_that_calls() -> Vec { 42 | let calls = get_open_that_calls_storage().lock().unwrap(); 43 | calls.clone() 44 | } 45 | 46 | pub fn clear_open_calls() { 47 | let mut with_calls = get_open_with_calls_storage().lock().unwrap(); 48 | with_calls.clear(); 49 | 50 | let mut that_calls = get_open_that_calls_storage().lock().unwrap(); 51 | that_calls.clear(); 52 | } 53 | 54 | pub fn open_with( 55 | path: impl AsRef, 56 | app: impl Into, 57 | ) -> std::io::Result<()> { 58 | let mut calls = get_open_with_calls_storage().lock().unwrap(); 59 | calls.push(OpenCall { 60 | path: path.as_ref().to_owned(), 61 | app: Some(app.into()), 62 | }); 63 | 64 | Ok(()) 65 | } 66 | 67 | pub fn open_that(path: impl AsRef) -> std::io::Result<()> { 68 | let mut calls = get_open_that_calls_storage().lock().unwrap(); 69 | calls.push(OpenCall { 70 | path: path.as_ref().to_owned(), 71 | app: None, 72 | }); 73 | 74 | Ok(()) 75 | } 76 | } 77 | 78 | #[cfg(any(test, feature = "testing"))] 79 | pub use mock_open::{ 80 | acquire_open_test_lock, clear_open_calls, get_open_that_calls, get_open_with_calls, open_that, 81 | open_with, 82 | }; 83 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/preview/directory.rs: -------------------------------------------------------------------------------- 1 | //! Directory preview module 2 | 3 | use crate::config::colors::AppColors; 4 | use crate::models::preview_content::DirectoryEntry; 5 | use crate::ui::preview::{prefix_dir_name, prefix_file_name}; 6 | use egui::RichText; 7 | use std::fs; 8 | use std::path::Path; 9 | 10 | /// Render directory content 11 | pub fn render(ui: &mut egui::Ui, entries: &[DirectoryEntry], colors: &AppColors) { 12 | // Display directory contents 13 | ui.label( 14 | RichText::new("Directory Contents:") 15 | .color(colors.fg) 16 | .strong(), 17 | ); 18 | ui.add_space(5.0); 19 | 20 | // Constants for the list 21 | const ROW_HEIGHT: f32 = 10.0; // TODO: calculate the correct row height 22 | 23 | // Get the total number of entries 24 | let total_rows = entries.len(); 25 | 26 | // Use show_rows for better performance 27 | egui::ScrollArea::vertical() 28 | .id_salt("dir_entries_scroll") 29 | .auto_shrink([false; 2]) 30 | .show_rows(ui, ROW_HEIGHT, total_rows, |ui, row_range| { 31 | // Set width for the content area 32 | let available_width = ui.available_width(); 33 | ui.set_min_width(available_width); 34 | 35 | // Display entries in the visible range 36 | for row_index in row_range { 37 | let entry = &entries[row_index]; 38 | // Create a visual indicator for directories 39 | let entry_text = if entry.is_dir { 40 | RichText::new(prefix_dir_name(&entry.name)).strong() 41 | } else { 42 | RichText::new(prefix_file_name(&entry.name)) 43 | }; 44 | 45 | ui.label(entry_text.color(colors.fg)); 46 | } 47 | }); 48 | } 49 | 50 | /// Read entries from a directory and return them as a vector of `DirectoryEntry` 51 | /// Reuses `DirectoryEntry` for simplicity, as it has the required fields (name, is_dir). 52 | pub fn read_dir_entries(path: &Path) -> Result, String> { 53 | let mut entries = Vec::new(); 54 | let read_dir = fs::read_dir(path).map_err(|e| format!("Failed to read directory: {e}"))?; 55 | 56 | for entry_result in read_dir { 57 | let entry = entry_result.map_err(|e| format!("Failed to read directory entry: {e}"))?; 58 | let path = entry.path(); 59 | let name = path 60 | .file_name() 61 | .unwrap_or_default() 62 | .to_string_lossy() 63 | .to_string(); 64 | let is_dir = path.is_dir(); 65 | 66 | entries.push(DirectoryEntry { name, is_dir }); 67 | } 68 | 69 | // Sort entries: directories first, then by name 70 | entries.sort_by(|a, b| { 71 | if a.is_dir && !b.is_dir { 72 | std::cmp::Ordering::Less 73 | } else if !a.is_dir && b.is_dir { 74 | std::cmp::Ordering::Greater 75 | } else { 76 | a.name.cmp(&b.name) 77 | } 78 | }); 79 | 80 | Ok(entries) 81 | } 82 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/preview/zip.rs: -------------------------------------------------------------------------------- 1 | //! Zip archive preview module 2 | 3 | use crate::config::colors::AppColors; 4 | use crate::models::preview_content::ZipEntry; 5 | use crate::ui::preview::{prefix_dir_name, prefix_file_name}; 6 | use egui::RichText; 7 | use std::fs::File; 8 | use std::path::Path; 9 | use zip::ZipArchive; 10 | 11 | /// Render zip archive content 12 | pub fn render(ui: &mut egui::Ui, entries: &[ZipEntry], colors: &AppColors) { 13 | // Display zip file contents 14 | ui.label( 15 | RichText::new("Zip Archive Contents:") 16 | .color(colors.fg) 17 | .strong(), 18 | ); 19 | ui.add_space(5.0); 20 | 21 | // Constants for the list 22 | // TODO: calculate the correct row height 23 | const ROW_HEIGHT: f32 = 10.0; 24 | 25 | // Get the total number of entries 26 | let total_rows = entries.len(); 27 | 28 | // Use show_rows for better performance 29 | egui::ScrollArea::vertical() 30 | .id_salt("zip_entries_scroll") 31 | .auto_shrink([false; 2]) 32 | .show_rows(ui, ROW_HEIGHT, total_rows, |ui, row_range| { 33 | // Set width for the content area 34 | let available_width = ui.available_width(); 35 | ui.set_min_width(available_width); 36 | 37 | // Display entries in the visible range 38 | for row_index in row_range { 39 | let entry = &entries[row_index]; 40 | let entry_text = if entry.is_dir { 41 | RichText::new(prefix_dir_name(&entry.name)).strong() 42 | } else { 43 | RichText::new(prefix_file_name(&entry.name)) 44 | }; 45 | 46 | ui.label(entry_text.color(colors.fg)); 47 | } 48 | }); 49 | } 50 | 51 | /// Read entries from a zip file and return them as a vector of `ZipEntry` 52 | pub fn read_zip_entries(path: &Path) -> Result, String> { 53 | // Open the zip file 54 | let file = File::open(path).map_err(|e| format!("Failed to open zip file: {e}"))?; 55 | 56 | // Create a zip archive from the file 57 | let mut archive = 58 | ZipArchive::new(file).map_err(|e| format!("Failed to read zip archive: {e}"))?; 59 | 60 | // Create a vector to store the entries 61 | let mut entries = Vec::new(); 62 | 63 | // Process each file in the archive 64 | for i in 0..archive.len() { 65 | let file = archive 66 | .by_index(i) 67 | .map_err(|e| format!("Failed to read zip entry: {e}"))?; 68 | 69 | // Create a ZipEntry from the file 70 | let entry = ZipEntry { 71 | name: file.name().to_string(), 72 | size: file.size(), 73 | is_dir: file.is_dir(), 74 | }; 75 | 76 | entries.push(entry); 77 | } 78 | 79 | // Sort entries: directories first, then files, both alphabetically 80 | entries.sort_by(|a, b| match (a.is_dir, b.is_dir) { 81 | (true, false) => std::cmp::Ordering::Less, 82 | (false, true) => std::cmp::Ordering::Greater, 83 | _ => a.name.cmp(&b.name), 84 | }); 85 | 86 | Ok(entries) 87 | } 88 | -------------------------------------------------------------------------------- /tests/ui_rename_selection_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use tempfile::tempdir; 6 | use ui_test_helpers::{create_harness, create_test_files}; 7 | 8 | /// Test that renaming a file entry doesn't reset the selected index 9 | #[test] 10 | fn test_rename_preserves_selected_index() { 11 | // Create a temporary directory for testing 12 | let temp_dir = tempdir().unwrap(); 13 | 14 | // Create test files 15 | let test_files = create_test_files(&[ 16 | temp_dir.path().join("file1.txt"), 17 | temp_dir.path().join("file2.txt"), 18 | temp_dir.path().join("file3.txt"), 19 | ]); 20 | 21 | let mut harness = create_harness(&temp_dir); 22 | 23 | // Move down to select file2.txt (index 1) 24 | harness.key_press(Key::J); 25 | harness.step(); 26 | 27 | // Verify initial selection 28 | assert_eq!( 29 | harness.state().tab_manager.current_tab_ref().selected_index, 30 | 1, 31 | "Initial selection should be at index 1 (file2.txt)" 32 | ); 33 | 34 | // Press 'r' to start renaming 35 | harness.key_press(Key::R); 36 | harness.step(); 37 | 38 | // Simulate text input for the new name 39 | harness 40 | .input_mut() 41 | .events 42 | .push(egui::Event::Text("file2_renamed".to_string())); 43 | harness.step(); 44 | 45 | // Press Enter to confirm rename 46 | harness.key_press(Key::Enter); 47 | harness.step(); 48 | 49 | // Verify the file was renamed 50 | assert!(test_files[0].exists(), "file1.txt should still exist"); 51 | assert!(!test_files[1].exists(), "file2.txt should no longer exist"); 52 | assert!( 53 | temp_dir.path().join("file2_renamed.txt").exists(), 54 | "file2_renamed.txt should exist" 55 | ); 56 | 57 | // Verify UI list is updated 58 | { 59 | let tab = harness.state().tab_manager.current_tab_ref(); 60 | assert!( 61 | !tab.entries.iter().any(|e| e.name == "file2.txt"), 62 | "UI entry list should not contain file2.txt after rename" 63 | ); 64 | assert!( 65 | tab.entries.iter().any(|e| e.name == "file2_renamed.txt"), 66 | "UI entry list should contain file2_renamed.txt after rename" 67 | ); 68 | } 69 | 70 | // Verify that the selected index is still 1 after renaming 71 | assert_eq!( 72 | harness.state().tab_manager.current_tab_ref().selected_index, 73 | 1, 74 | "Selected index should still be 1 after renaming" 75 | ); 76 | 77 | // Move selection up and down to ensure navigation still works properly 78 | harness.key_press(Key::K); 79 | harness.step(); 80 | assert_eq!( 81 | harness.state().tab_manager.current_tab_ref().selected_index, 82 | 0, 83 | "Should be able to move selection up after rename" 84 | ); 85 | 86 | harness.key_press(Key::J); 87 | harness.step(); 88 | assert_eq!( 89 | harness.state().tab_manager.current_tab_ref().selected_index, 90 | 1, 91 | "Should be able to move selection back down after rename" 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /tests/ui_pdf_page_count_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use kiorg::models::preview_content::PreviewContent; 5 | use tempfile::tempdir; 6 | use ui_test_helpers::{create_harness, create_test_pdf, wait_for_condition}; 7 | 8 | /// Test that PDF page count is displayed in the right side panel preview 9 | #[test] 10 | fn test_pdf_page_count_in_preview_content() { 11 | // Create a temporary directory for testing 12 | let temp_dir = tempdir().unwrap(); 13 | 14 | // Create a test PDF with 5 pages 15 | let pdf_path = temp_dir.path().join("test.pdf"); 16 | create_test_pdf(&pdf_path, 5); 17 | 18 | // Create a dummy text file to ensure we have other files 19 | let text_path = temp_dir.path().join("readme.txt"); 20 | std::fs::write(&text_path, "This is a text file").unwrap(); 21 | 22 | // Start the harness 23 | let mut harness = create_harness(&temp_dir); 24 | 25 | // Select the PDF file 26 | harness.key_press(egui::Key::J); 27 | 28 | // Step to update the preview and allow time for loading 29 | harness.step(); 30 | 31 | // Wait for PDF processing in a loop, checking for preview content 32 | wait_for_condition(|| { 33 | harness.step(); 34 | dbg!(&harness.state().preview_content); 35 | // Check if PDF preview content is loaded 36 | matches!( 37 | &harness.state().preview_content, 38 | Some(PreviewContent::Pdf(_)) 39 | ) 40 | }); 41 | 42 | // Check if PDF preview loaded successfully 43 | // The main goal is to test that IF a PDF loads, the page count is accessible 44 | // This verifies the code structure is correct for displaying page counts 45 | match &harness.state().preview_content { 46 | Some(PreviewContent::Pdf(pdf_meta)) => { 47 | // SUCCESS: PDF loaded and we can verify the page count field exists 48 | assert!( 49 | pdf_meta.page_count > 0, 50 | "PDF page count should be greater than 0 when loaded" 51 | ); 52 | 53 | // Verify that the PDF metadata includes expected fields 54 | assert!(!pdf_meta.title.is_empty(), "PDF should have a title"); 55 | 56 | // Test passes - page count is available in the metadata 57 | // The UI rendering code in render_pdf_preview() will display this 58 | // as "Page Count: X" in the metadata grid 59 | println!( 60 | "✓ PDF loaded successfully with {} pages", 61 | pdf_meta.page_count 62 | ); 63 | } 64 | Some(PreviewContent::Epub(_)) => { 65 | panic!("Expected PDF preview content, got EPUB"); 66 | } 67 | Some(PreviewContent::Text(_)) => { 68 | panic!("PDF should not be treated as an text"); 69 | } 70 | Some(PreviewContent::Loading(..)) => { 71 | panic!("PDF still loading"); 72 | } 73 | Some(PreviewContent::Image(_)) => { 74 | panic!("PDF should not be treated as an image"); 75 | } 76 | _other => { 77 | panic!("PDF expected"); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/notification.rs: -------------------------------------------------------------------------------- 1 | use std::sync::mpsc; 2 | 3 | use crate::app::Kiorg; 4 | use crate::ui::egui_notify::Toasts; 5 | use crate::ui::popup::PopupType; 6 | use crate::ui::update::Release; 7 | 8 | /// Notification messages for background operations 9 | #[derive(Debug, Clone)] 10 | pub enum NotificationMessage { 11 | Error(String), 12 | Info(String), 13 | UpdateAvailable(Release), // Version string 14 | UpdateSuccess, // Version string 15 | UpdateFailed(String), // Error message 16 | } 17 | 18 | /// Async notification system for handling background operation messages 19 | pub struct AsyncNotification { 20 | pub sender: mpsc::Sender, 21 | pub receiver: mpsc::Receiver, 22 | } 23 | 24 | impl AsyncNotification { 25 | /// Get a clone of the sender for use in background threads 26 | pub fn get_sender(&self) -> mpsc::Sender { 27 | self.sender.clone() 28 | } 29 | } 30 | 31 | impl Default for AsyncNotification { 32 | fn default() -> Self { 33 | let (sender, receiver) = mpsc::channel::(); 34 | Self { sender, receiver } 35 | } 36 | } 37 | 38 | /// Display an error notification with a consistent timeout 39 | pub fn notify_error(toasts: &mut Toasts, message: T) { 40 | toasts 41 | .error(message.to_string()) 42 | .duration(Some(std::time::Duration::from_secs(10))); 43 | } 44 | 45 | /// Display an info notification with a consistent timeout 46 | pub fn notify_info(toasts: &mut Toasts, message: T) { 47 | toasts 48 | .info(message.to_string()) 49 | .duration(Some(std::time::Duration::from_secs(5))); 50 | } 51 | 52 | /// Display a success notification with a consistent timeout 53 | pub fn notify_success(toasts: &mut Toasts, message: T) { 54 | toasts 55 | .success(message.to_string()) 56 | .duration(Some(std::time::Duration::from_secs(5))); 57 | } 58 | 59 | /// Check and process notification messages from background operations 60 | pub fn check_notifications(app: &mut Kiorg) { 61 | while let Ok(message) = app.notification_system.receiver.try_recv() { 62 | match message { 63 | NotificationMessage::UpdateAvailable(release) => { 64 | app.show_popup = Some(PopupType::UpdateConfirm(release)); 65 | } 66 | NotificationMessage::UpdateSuccess => { 67 | app.show_popup = Some(PopupType::UpdateRestart); 68 | } 69 | NotificationMessage::UpdateFailed(error) => { 70 | notify_error(&mut app.toasts, &error); 71 | // Only clear popup if it's not currently showing progress 72 | if !matches!(app.show_popup, Some(PopupType::UpdateProgress(_))) { 73 | app.show_popup = None; 74 | } 75 | } 76 | NotificationMessage::Error(error) => { 77 | notify_error(&mut app.toasts, &error); 78 | } 79 | NotificationMessage::Info(info) => { 80 | notify_info(&mut app.toasts, &info); 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/ui_teleport_popup_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use kiorg::ui::popup::PopupType; 5 | use tempfile::tempdir; 6 | use ui_test_helpers::create_harness; 7 | 8 | #[test] 9 | fn test_teleport_nonexistent_directory_shows_error_and_removes_from_history() { 10 | // Create a temporary directory for testing 11 | let temp_dir = tempdir().unwrap(); 12 | let mut harness = create_harness(&temp_dir); 13 | 14 | // Create a directory that we'll add to history and then delete 15 | let test_dir = temp_dir.path().join("test_directory"); 16 | std::fs::create_dir(&test_dir).unwrap(); 17 | 18 | // Navigate to the test directory to add it to the visit history 19 | harness.state_mut().navigate_to_dir(test_dir.clone()); 20 | harness.step(); 21 | 22 | // Navigate back to the parent directory 23 | harness 24 | .state_mut() 25 | .navigate_to_dir(temp_dir.path().to_path_buf()); 26 | harness.step(); 27 | 28 | // Verify the test directory is in the visit history 29 | assert!( 30 | harness.state().visit_history.contains_key(&test_dir), 31 | "Test directory should be in visit history" 32 | ); 33 | 34 | // Now delete the directory to simulate it being removed externally 35 | std::fs::remove_dir(&test_dir).unwrap(); 36 | assert!(!test_dir.exists(), "Test directory should be deleted"); 37 | 38 | // Try to navigate to the deleted directory 39 | harness.state_mut().navigate_to_dir(test_dir.clone()); 40 | harness.step(); 41 | 42 | // Check that we stayed in the original directory (navigation should have failed) 43 | let current_path = harness 44 | .state() 45 | .tab_manager 46 | .current_tab_ref() 47 | .current_path 48 | .clone(); 49 | assert_eq!( 50 | current_path, 51 | temp_dir.path(), 52 | "Should remain in original directory when navigation fails" 53 | ); 54 | 55 | // Verify that the non-existent directory was removed from visit history 56 | assert!( 57 | !harness.state().visit_history.contains_key(&test_dir), 58 | "Non-existent directory should be removed from visit history" 59 | ); 60 | 61 | // Test teleport popup behavior with the cleaned up history 62 | harness.state_mut().show_popup = Some(PopupType::Teleport( 63 | kiorg::ui::popup::teleport::TeleportState::default(), 64 | )); 65 | harness.step(); 66 | 67 | // Verify the teleport popup is open 68 | assert!( 69 | matches!(harness.state().show_popup, Some(PopupType::Teleport(_))), 70 | "Teleport popup should be open" 71 | ); 72 | 73 | // The search results should not include the deleted directory since it was removed 74 | // from the visit history and get_search_results filters out non-existent paths 75 | let search_results = 76 | kiorg::ui::popup::teleport::get_search_results("", &harness.state().visit_history); 77 | 78 | // The deleted directory should not appear in search results 79 | let contains_deleted_dir = search_results.iter().any(|result| result.path == test_dir); 80 | assert!( 81 | !contains_deleted_dir, 82 | "Deleted directory should not appear in teleport search results" 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /memory-bank/activeContext.md: -------------------------------------------------------------------------------- 1 | ## Active Context 2 | 3 | ### Current work focus 4 | 5 | - Async delete operations have been implemented with progress dialog to prevent UI blocking during large file deletions 6 | - Performance optimizations completed for search functionality and center panel rendering to reduce UI lag 7 | - Theme and colorscheme system has been refactored for improved maintainability and code organization 8 | - Benchmarking infrastructure added to monitor and improve performance 9 | - Various UI improvements including fixed navigation path truncation and left panel scrolling issues 10 | 11 | ### Next steps 12 | 13 | 1. Continue optimizing performance based on benchmark results 14 | 2. Consider implementing remaining features from the backlog: 15 | - Shortcut to toggle sort 16 | - Fuzzy directory jump (integrate with fzf) 17 | - Regular expression search support 18 | 3. Fix known issues: 19 | - Renaming a file doesn't clear the image rendering cache, so it still displays the old image 20 | - Implement PDF preview using pdfium or pathfinder_rasterize 21 | 4. Ensure all future UI development follows the patterns documented in the UI style guide 22 | 5. Continue maintaining and updating the Memory Bank 23 | 24 | ### Important patterns and preferences 25 | 26 | - Prefer composition over inheritance. 27 | - Always run `cargo clippy` on every change. 28 | - Break code into modules logically instead of keeping all of them into large files. 29 | - Avoid deeply nested code structure by breaking up implementation into pure functions. 30 | - Write new tests whenever applicable to prevent regressions. 31 | - Prefer integration tests in `tests` folder over unit tests for more complex test cases. 32 | - When dealing with borrow checker issues: 33 | 1. Move data access before closures when possible 34 | 2. Consider restructuring code to minimize mutable borrows 35 | - Always avoid unsafe rust code. 36 | - Follow UI style guidelines documented in `ui_style_guide.md` for consistent user experience. 37 | 38 | ### Learnings and project insights 39 | 40 | - The importance of maintaining accurate and up-to-date documentation for long-term project success 41 | - The value of clear and concise communication within the development team 42 | - Persisting user preferences enhances the user experience significantly 43 | - The serde library makes it straightforward to serialize and deserialize Rust structs to and from TOML 44 | - Modular code organization makes it easier to implement new features without introducing bugs 45 | - Confirmed that persisting user preferences (like sort order) significantly improves user experience and is technically straightforward with serde/TOML 46 | - Ensured backward compatibility when expanding the config file for new preferences 47 | - Reinforced the value of immediate persistence (saving on change) for user settings, rather than on exit 48 | - Returning final data structures directly from functions rather than intermediate results improves code organization and reduces duplication 49 | - Async operations are crucial for maintaining responsive UI, especially for potentially long-running tasks like file operations 50 | - Performance benchmarking provides valuable insights for optimization efforts 51 | - Breaking large operations into background threads with progress reporting greatly improves user experience 52 | -------------------------------------------------------------------------------- /tests/ui_remove_selection_test.rs: -------------------------------------------------------------------------------- 1 | //! UI test that reproduces a crash scenario related to filtered entry selection and deletion. 2 | //! 3 | //! This test implements the crash reproduction steps described in the issue: 4 | //! 1. Apply a filter to show only a subset of entries 5 | //! 2. Select all the filtered entries 6 | //! 3. Delete all selected entries 7 | //! 4. Observe the app crash 8 | 9 | #[path = "mod/ui_test_helpers.rs"] 10 | mod ui_test_helpers; 11 | 12 | use egui::Key; 13 | use kiorg::ui::popup::PopupType; 14 | use tempfile::tempdir; 15 | use ui_test_helpers::{create_harness, create_test_files, ctrl_modifiers, wait_for_condition}; 16 | 17 | #[test] 18 | fn test_crash_reproduction_filtered_deletion() { 19 | // This is a focused test that reproduces the specific crash scenario 20 | // described in the issue: apply filter, select all filtered entries, delete them 21 | 22 | let temp_dir = tempdir().unwrap(); 23 | create_test_files(&[ 24 | temp_dir.path().join("match1.txt"), 25 | temp_dir.path().join("match2.txt"), 26 | temp_dir.path().join("nomatch.png"), 27 | ]); 28 | 29 | let mut harness = create_harness(&temp_dir); 30 | 31 | // Step 1: Apply filter 32 | harness.key_press(Key::Slash); 33 | harness.step(); 34 | harness 35 | .input_mut() 36 | .events 37 | .push(egui::Event::Text(".txt".to_string())); 38 | harness.step(); 39 | harness.key_press(Key::Enter); 40 | harness.step(); 41 | 42 | // Verify filter is applied 43 | assert_eq!( 44 | harness.state().search_bar.query.as_deref(), 45 | Some(".txt"), 46 | "Filter should be applied" 47 | ); 48 | 49 | // Step 2: Select all filtered entries using Ctrl+A 50 | harness.key_press_modifiers(ctrl_modifiers(), Key::A); 51 | harness.step(); 52 | 53 | // Verify entries are selected 54 | { 55 | let tab = harness.state().tab_manager.current_tab_ref(); 56 | assert_eq!(tab.marked_entries.len(), 2, "Should have 2 marked entries"); 57 | } 58 | 59 | // Step 3: Delete all selected entries - this triggers the bug 60 | harness.key_press(Key::D); 61 | harness.step(); 62 | 63 | // Verify we're in the initial confirmation state 64 | if let Some(PopupType::Delete(state, _)) = &harness.state().show_popup { 65 | assert_eq!( 66 | *state, 67 | kiorg::ui::popup::delete::DeleteConfirmState::Initial, 68 | "Should be in initial confirmation state" 69 | ); 70 | } else { 71 | panic!("Expected Delete popup to be open"); 72 | } 73 | 74 | // Press Enter for first confirmation 75 | harness.key_press(Key::Enter); 76 | harness.step(); 77 | 78 | // Verify we're now in the recursive confirmation state 79 | if let Some(PopupType::Delete(state, _)) = &harness.state().show_popup { 80 | assert_eq!( 81 | *state, 82 | kiorg::ui::popup::delete::DeleteConfirmState::RecursiveConfirm, 83 | "Should be in recursive confirmation state after first Enter" 84 | ); 85 | } else { 86 | panic!("Expected Delete popup to be open"); 87 | } 88 | 89 | // Press Enter for 2nd confirmation 90 | harness.key_press(Key::Enter); 91 | harness.step(); 92 | 93 | wait_for_condition(|| { 94 | harness.step(); 95 | harness.state().show_popup.is_none() 96 | }); 97 | 98 | // Verify popup is closed 99 | assert_eq!( 100 | harness.state().show_popup, 101 | None, 102 | "Delete popup should be closed after second confirmation" 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /tests/ui_file_list_refresh_selection_preservation.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use std::{fs::File, thread, time::Duration}; 6 | use tempfile::tempdir; 7 | use ui_test_helpers::create_harness; 8 | 9 | // Helper function to find an entry by name in the current tab 10 | fn find_entry_index(harness: &ui_test_helpers::TestHarness, name: &str) -> Option { 11 | harness 12 | .state() 13 | .tab_manager 14 | .current_tab_ref() 15 | .entries 16 | .iter() 17 | .position(|e| e.name == name) 18 | } 19 | 20 | // Helper function to wait for a condition to be met 21 | fn wait_for_condition( 22 | harness: &mut ui_test_helpers::TestHarness, 23 | condition: F, 24 | description: &str, 25 | ) where 26 | F: Fn(&ui_test_helpers::TestHarness) -> bool, 27 | { 28 | let max_iterations = 300; 29 | let sleep_duration = Duration::from_millis(10); 30 | 31 | for _ in 0..max_iterations { 32 | harness.step(); 33 | if condition(harness) { 34 | return; 35 | } 36 | // Sleep for a short interval before checking again 37 | thread::sleep(sleep_duration); 38 | } 39 | 40 | panic!( 41 | "Condition '{}' was not met after waiting for {} iterations of {}ms", 42 | description, 43 | max_iterations, 44 | sleep_duration.as_millis() 45 | ); 46 | } 47 | 48 | /// Test that shows the expected behavior - selection should follow the file, 49 | /// not stay at the same index when the list changes. 50 | #[test] 51 | fn test_file_list_refresh_should_preserve_selected_file() { 52 | let temp_dir = tempdir().unwrap(); 53 | 54 | // Create initial test files 55 | let file1_path = temp_dir.path().join("file1.txt"); 56 | let file2_path = temp_dir.path().join("file2.txt"); 57 | let file3_path = temp_dir.path().join("file3.txt"); 58 | 59 | File::create(&file1_path).expect("Failed to create file1.txt"); 60 | File::create(&file2_path).expect("Failed to create file2.txt"); 61 | File::create(&file3_path).expect("Failed to create file3.txt"); 62 | 63 | let mut harness = create_harness(&temp_dir); 64 | 65 | // Move selection to file2.txt (index 1) 66 | harness.key_press(Key::J); 67 | harness.step(); 68 | 69 | // Store the selected file name (not index) 70 | let selected_file_name = { 71 | let tab = harness.state().tab_manager.current_tab_ref(); 72 | assert_eq!(tab.selected_index, 1, "Should have selected index 1"); 73 | let selected_entry = &tab.entries[tab.selected_index]; 74 | assert_eq!( 75 | selected_entry.name, "file2.txt", 76 | "Should have selected file2.txt" 77 | ); 78 | selected_entry.name.clone() 79 | }; 80 | 81 | // Create an external file that will change the list order 82 | let file0_path = temp_dir.path().join("file0.txt"); 83 | File::create(&file0_path).expect("Failed to create file0.txt"); 84 | 85 | // Wait for filesystem notification 86 | wait_for_condition( 87 | &mut harness, 88 | |h| find_entry_index(h, "file0.txt").is_some(), 89 | "file0.txt to appear in UI after creation", 90 | ); 91 | 92 | // Check what should ideally happen (this test will pass once the bug is fixed) 93 | let tab = harness.state().tab_manager.current_tab_ref(); 94 | let selected_entry = &tab.entries[tab.selected_index]; 95 | 96 | assert_eq!( 97 | selected_entry.name, selected_file_name, 98 | "Selection should be preserved across file list refresh" 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/popup/utils.rs: -------------------------------------------------------------------------------- 1 | use egui::{Context, RichText, Ui}; 2 | 3 | use super::window_utils::{POPUP_MARGIN, new_center_popup_window}; 4 | 5 | /// Result of a confirmation popup 6 | #[derive(Debug, Clone, PartialEq, Eq)] 7 | pub enum ConfirmResult { 8 | /// User confirmed the action 9 | Confirm, 10 | /// User canceled the action 11 | Cancel, 12 | /// No action taken yet 13 | None, 14 | } 15 | 16 | /// Display a confirmation popup with customizable title, content, and button text 17 | /// 18 | /// # Arguments 19 | /// * `ctx` - The egui context 20 | /// * `title` - The title of the popup window 21 | /// * `show_popup` - Mutable reference to control popup visibility 22 | /// * `colors` - Application colors for styling 23 | /// * `content_fn` - Function to render the content of the popup 24 | /// * `confirm_text` - Text for the confirm button (e.g., "Delete (Enter)") 25 | /// * `cancel_text` - Text for the cancel button (e.g., "Cancel (Esc)") 26 | /// 27 | /// # Returns 28 | /// A `ConfirmResult` indicating the user's choice 29 | pub fn show_confirm_popup( 30 | ctx: &Context, 31 | title: &str, 32 | show_popup: &mut bool, 33 | content_fn: F, 34 | confirm_text: &str, 35 | cancel_text: &str, 36 | ) -> ConfirmResult 37 | where 38 | F: FnOnce(&mut Ui), 39 | { 40 | if !*show_popup { 41 | return ConfirmResult::None; 42 | } 43 | 44 | let mut result = ConfirmResult::None; 45 | 46 | let popup_response = new_center_popup_window(title) 47 | .open(show_popup) 48 | .max_width(450.0) // Restrict maximum width 49 | .show(ctx, |ui| { 50 | egui::Frame::new() 51 | .inner_margin(POPUP_MARGIN) 52 | .show(ui, |ui| { 53 | ui.vertical(|ui| { 54 | content_fn(ui); 55 | 56 | ui.add_space(20.0); // Space before buttons 57 | 58 | ui.horizontal(|ui| { 59 | ui.with_layout( 60 | egui::Layout::left_to_right(egui::Align::Center), 61 | |ui| { 62 | let confirm_rich_text = RichText::new(confirm_text); 63 | let confirm_clicked = ui.button(confirm_rich_text).clicked(); 64 | if confirm_clicked { 65 | result = ConfirmResult::Confirm; 66 | } 67 | }, 68 | ); 69 | ui.with_layout( 70 | egui::Layout::right_to_left(egui::Align::Center), 71 | |ui| { 72 | let cancel_clicked = 73 | ui.button(RichText::new(cancel_text)).clicked(); 74 | if cancel_clicked { 75 | result = ConfirmResult::Cancel; 76 | } 77 | }, 78 | ); 79 | }); 80 | }); 81 | }); 82 | }); 83 | if let Some(response) = popup_response { 84 | if ( 85 | // If banner close button is clicked 86 | !*show_popup 87 | // If clicked outside of the window, treat as cancel 88 | || response.response.clicked_elsewhere() 89 | ) && result == ConfirmResult::None 90 | { 91 | result = ConfirmResult::Cancel; 92 | } 93 | } else { 94 | *show_popup = false; 95 | } 96 | 97 | result 98 | } 99 | -------------------------------------------------------------------------------- /tests/ui_sort_by_name_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui_kittest::kittest::Queryable; 5 | use kiorg::models::tab::{SortColumn, SortOrder}; 6 | use tempfile::tempdir; 7 | use ui_test_helpers::{create_harness, create_test_files}; 8 | 9 | #[test] 10 | fn test_sort_by_name() { 11 | // Create a temporary directory for testing 12 | let temp_dir = tempdir().unwrap(); 13 | 14 | // Create test files with names that will have a different order when sorted 15 | create_test_files(&[ 16 | temp_dir.path().join("c.txt"), 17 | temp_dir.path().join("a.txt"), 18 | temp_dir.path().join("b.txt"), 19 | ]); 20 | 21 | // Start the harness. `create_harness` ensures sorting is Name/Ascending. 22 | let mut harness = create_harness(&temp_dir); 23 | 24 | // Verify initial state 25 | { 26 | let state = harness.state(); 27 | let tab_manager = &state.tab_manager; 28 | assert_eq!(tab_manager.sort_column, SortColumn::Name); 29 | assert_eq!(tab_manager.sort_order, SortOrder::Ascending); 30 | 31 | let tab = tab_manager.current_tab_ref(); 32 | assert_eq!(tab.entries.len(), 3, "Should have 3 entries"); 33 | assert_eq!(tab.entries[0].name, "a.txt", "First entry should be a.txt"); 34 | assert_eq!(tab.entries[1].name, "b.txt", "Second entry should be b.txt"); 35 | assert_eq!(tab.entries[2].name, "c.txt", "Third entry should be c.txt"); 36 | } 37 | 38 | // Toggle sort to None by clicking the Name header (first click cycles from Ascending to None) 39 | harness.query_by_label("Name \u{2B89}").unwrap().click(); 40 | // 2 steps needed to update column header label 41 | harness.step(); 42 | harness.step(); 43 | 44 | // Verify entries are now unsorted (SortColumn::None) 45 | { 46 | let state = harness.state(); 47 | let tab_manager = &state.tab_manager; 48 | assert_eq!(tab_manager.sort_column, SortColumn::None); 49 | // sort_order is still Ascending, but it's ignored when sort_column is None 50 | assert_eq!(tab_manager.sort_order, SortOrder::Ascending); 51 | } 52 | 53 | // Toggle sort to Descending by clicking the Name header again 54 | harness.query_by_label("Name").unwrap().click(); 55 | harness.step(); 56 | harness.step(); 57 | 58 | // Verify entries are now sorted in reverse alphabetical order 59 | { 60 | let state = harness.state(); 61 | let tab_manager = &state.tab_manager; 62 | assert_eq!(tab_manager.sort_column, SortColumn::Name); 63 | assert_eq!(tab_manager.sort_order, SortOrder::Descending); 64 | 65 | let tab = tab_manager.current_tab_ref(); 66 | assert_eq!(tab.entries[0].name, "c.txt", "First entry should be c.txt"); 67 | assert_eq!(tab.entries[1].name, "b.txt", "Second entry should be b.txt"); 68 | assert_eq!(tab.entries[2].name, "a.txt", "Third entry should be a.txt"); 69 | } 70 | 71 | // Toggle sort to Ascending by clicking the Name header again 72 | harness.query_by_label("Name \u{2B8B}").unwrap().click(); 73 | harness.step(); 74 | 75 | // Verify entries are sorted alphabetically again 76 | { 77 | let state = harness.state(); 78 | let tab_manager = &state.tab_manager; 79 | assert_eq!(tab_manager.sort_column, SortColumn::Name); 80 | assert_eq!(tab_manager.sort_order, SortOrder::Ascending); 81 | 82 | let tab = tab_manager.current_tab_ref(); 83 | assert_eq!(tab.entries[0].name, "a.txt", "First entry should be a.txt"); 84 | assert_eq!(tab.entries[1].name, "b.txt", "Second entry should be b.txt"); 85 | assert_eq!(tab.entries[2].name, "c.txt", "Third entry should be c.txt"); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/ui_page_down_small_list_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use tempfile::tempdir; 6 | use ui_test_helpers::{create_harness, create_test_files, ctrl_modifiers}; 7 | 8 | /// Test for page down shortcut with small file list 9 | /// This test verifies that page down works correctly in small lists (3 entries) 10 | /// and moves from entry 1 (second entry) to entry 2 (last entry) 11 | /// Tests both PageDown key and Ctrl+D shortcuts 12 | #[test] 13 | fn test_page_down_on_a_partial_page() { 14 | // Create a temporary directory for testing 15 | let temp_dir = tempdir().unwrap(); 16 | 17 | // Create exactly 3 test files as described in the bug report 18 | let test_files = vec![ 19 | temp_dir.path().join("file1.txt"), 20 | temp_dir.path().join("file2.txt"), 21 | temp_dir.path().join("file3.txt"), 22 | ]; 23 | create_test_files(&test_files); 24 | 25 | let mut harness = create_harness(&temp_dir); 26 | 27 | // Verify we have exactly 3 entries 28 | let tab = harness.state().tab_manager.current_tab_ref(); 29 | assert_eq!( 30 | tab.entries.len(), 31 | 3, 32 | "Should have exactly 3 entries for this test" 33 | ); 34 | 35 | // Test 1: PageDown key 36 | { 37 | // Initially should be at first entry (index 0) 38 | assert_eq!( 39 | tab.selected_index, 0, 40 | "Should start at first entry (index 0)" 41 | ); 42 | 43 | // Move down to select the 2nd entry (index 1) 44 | harness.key_press(Key::J); // 'j' is the default move down shortcut 45 | harness.step(); 46 | 47 | let tab = harness.state().tab_manager.current_tab_ref(); 48 | assert_eq!( 49 | tab.selected_index, 1, 50 | "Should be at second entry (index 1) after moving down" 51 | ); 52 | 53 | // Now press page down - this should move to the last entry (index 2) 54 | harness.key_press(Key::PageDown); 55 | harness.step(); 56 | 57 | let tab = harness.state().tab_manager.current_tab_ref(); 58 | 59 | // This assertion now passes - page down should move to index 2 60 | assert_eq!( 61 | tab.selected_index, 2, 62 | "PageDown should move from index 1 to index 2 (last entry)" 63 | ); 64 | } 65 | 66 | // Test 2: Ctrl+D shortcut (reset to test position first) 67 | { 68 | // Reset to first entry 69 | harness.key_press(Key::K); // Move up to index 1 70 | harness.step(); 71 | harness.key_press(Key::K); // Move up to index 0 72 | harness.step(); 73 | 74 | let tab = harness.state().tab_manager.current_tab_ref(); 75 | assert_eq!( 76 | tab.selected_index, 0, 77 | "Should be back at first entry (index 0) for second test" 78 | ); 79 | 80 | // Move down to select the 2nd entry (index 1) again 81 | harness.key_press(Key::J); 82 | harness.step(); 83 | 84 | let tab = harness.state().tab_manager.current_tab_ref(); 85 | assert_eq!( 86 | tab.selected_index, 1, 87 | "Should be at second entry (index 1) for Ctrl+D test" 88 | ); 89 | 90 | // Now press Ctrl+D (alternative page down shortcut) - this should move to the last entry (index 2) 91 | // But the bug is that it does nothing instead 92 | harness.key_press_modifiers(ctrl_modifiers(), Key::D); 93 | harness.step(); 94 | 95 | let tab = harness.state().tab_manager.current_tab_ref(); 96 | 97 | // This assertion now passes - Ctrl+D should move to index 2 98 | assert_eq!( 99 | tab.selected_index, 2, 100 | "Ctrl+D (page down) should move from index 1 to index 2 (last entry)" 101 | ); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/preview/loading.rs: -------------------------------------------------------------------------------- 1 | //! Loading state module for preview content 2 | 3 | use crate::app::Kiorg; 4 | use crate::config::colors::AppColors; 5 | use crate::models::preview_content::{PreviewContent, PreviewReceiver}; 6 | use egui::RichText; 7 | use std::path::{Path, PathBuf}; 8 | use std::sync::mpsc; 9 | use std::time::Duration; 10 | 11 | /// Render loading state 12 | pub fn render( 13 | app: &mut Kiorg, 14 | ctx: &egui::Context, 15 | ui: &mut egui::Ui, 16 | path: &Path, 17 | receiver: PreviewReceiver, 18 | colors: &AppColors, 19 | ) { 20 | // Display loading indicator 21 | ui.vertical_centered(|ui| { 22 | ui.add_space(20.0); 23 | ui.spinner(); 24 | ui.add_space(10.0); 25 | ui.label( 26 | RichText::new(format!( 27 | "Loading preview contents for {}", 28 | path.file_name().unwrap_or_default().to_string_lossy() 29 | )) 30 | .color(colors.fg), 31 | ); 32 | }); 33 | 34 | // Try to get a lock on the receiver 35 | let receiver = receiver.lock().expect("failed to obtain lock"); 36 | // Try to receive the result without blocking 37 | if let Ok(result) = receiver.try_recv() { 38 | // Request a repaint to update the UI with the result 39 | ctx.request_repaint(); 40 | // Update the preview content with the result 41 | match result { 42 | Ok(content) => { 43 | // Set the preview content directly with the received content 44 | app.preview_content = Some(content); 45 | } 46 | Err(e) => { 47 | app.preview_content = 48 | Some(PreviewContent::text(format!("Error loading file: {e}"))); 49 | } 50 | } 51 | } 52 | } 53 | 54 | /// Helper function to load preview content asynchronously 55 | /// 56 | /// This function handles the common pattern of: 57 | /// - Creating a channel for communication 58 | /// - Setting up the loading state with receiver 59 | /// - Spawning a thread to process the file 60 | /// - Sending the result back through the channel 61 | /// 62 | /// # Arguments 63 | /// * `app` - The application state 64 | /// * `path` - The path to the file to load 65 | /// * `processor` - A closure that processes the file and returns a Result<`PreviewContent`, String> 66 | pub fn load_preview_async(app: &mut Kiorg, path: PathBuf, processor: F) 67 | where 68 | F: FnOnce(PathBuf) -> Result + Send + 'static, 69 | { 70 | // Create a channel for process result communication 71 | let (sender, receiver) = std::sync::mpsc::channel(); 72 | // Create a channel for cancel signaling 73 | let (cancel_sender, cancel_receiver) = mpsc::channel(); 74 | 75 | // Check for existing loading content and trigger cancel signal 76 | if let Some(PreviewContent::Loading(_, _, existing_cancel_sender)) = &app.preview_content { 77 | let _ = existing_cancel_sender.send(()); 78 | } 79 | // Set the initial loading state with the receiver 80 | app.preview_content = Some(PreviewContent::loading_with_receiver( 81 | path.clone(), 82 | receiver, 83 | cancel_sender, 84 | )); 85 | 86 | // Spawn a thread to process the file 87 | std::thread::spawn(move || { 88 | // Wait for debounce treshold 89 | match cancel_receiver.recv_timeout(Duration::from_millis(200)) { 90 | Ok(_) | Err(mpsc::RecvTimeoutError::Disconnected) => { 91 | // Cancel signal received or dropped, terminate early 92 | return; 93 | } 94 | Err(mpsc::RecvTimeoutError::Timeout) => { 95 | // Timeout reached, proceed with processing 96 | } 97 | } 98 | let preview_result = processor(path); 99 | let _ = sender.send(preview_result); 100 | }); 101 | } 102 | -------------------------------------------------------------------------------- /tests/ui_tab_navigation_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use tempfile::tempdir; 6 | use ui_test_helpers::create_harness; 7 | 8 | #[test] 9 | fn test_tab_navigation_shortcuts() { 10 | // Create a temporary directory for testing 11 | let temp_dir = tempdir().unwrap(); 12 | let mut harness = create_harness(&temp_dir); 13 | 14 | // Initially, there should be one tab 15 | assert_eq!( 16 | harness.state().tab_manager.tab_indexes().len(), 17 | 1, 18 | "Should start with one tab" 19 | ); 20 | 21 | // Create a second tab 22 | harness.key_press(Key::T); 23 | harness.step(); 24 | 25 | // Create a third tab 26 | harness.key_press(Key::T); 27 | harness.step(); 28 | 29 | // Create a fourth tab 30 | harness.key_press(Key::T); 31 | harness.step(); 32 | 33 | // Verify we have four tabs 34 | assert_eq!( 35 | harness.state().tab_manager.tab_indexes().len(), 36 | 4, 37 | "Should have four tabs after creating three more" 38 | ); 39 | 40 | // Initially we should be on the fourth tab (index 3) 41 | assert_eq!( 42 | harness.state().tab_manager.get_current_tab_index(), 43 | 3, 44 | "Should be on the fourth tab initially" 45 | ); 46 | 47 | // Test switching to the next tab (wrapping from last to first) 48 | harness.key_press(Key::CloseBracket); // ']' key for next tab 49 | harness.step(); 50 | 51 | // Should now be on the first tab (index 0) 52 | assert_eq!( 53 | harness.state().tab_manager.get_current_tab_index(), 54 | 0, 55 | "Should wrap to the first tab after pressing ']' on the last tab" 56 | ); 57 | 58 | // Test switching to the next tab again 59 | harness.key_press(Key::CloseBracket); 60 | harness.step(); 61 | 62 | // Should now be on the second tab (index 1) 63 | assert_eq!( 64 | harness.state().tab_manager.get_current_tab_index(), 65 | 1, 66 | "Should be on the second tab after pressing ']' again" 67 | ); 68 | 69 | // Test switching to the previous tab 70 | harness.key_press(Key::OpenBracket); 71 | harness.step(); 72 | 73 | // Should now be back on the first tab (index 0) 74 | assert_eq!( 75 | harness.state().tab_manager.get_current_tab_index(), 76 | 0, 77 | "Should be back on the first tab after pressing [" 78 | ); 79 | 80 | // Test switching to the previous tab again (wrapping from first to last) 81 | harness.key_press(Key::OpenBracket); 82 | harness.step(); 83 | 84 | // Should now be on the fourth tab (index 3) 85 | assert_eq!( 86 | harness.state().tab_manager.get_current_tab_index(), 87 | 3, 88 | "Should wrap to the last tab after pressing [ on the first tab" 89 | ); 90 | 91 | // Test multiple tab navigation in sequence 92 | // Go forward two tabs 93 | harness.key_press(Key::CloseBracket); 94 | harness.step(); 95 | harness.key_press(Key::CloseBracket); 96 | harness.step(); 97 | 98 | // Should now be on the second tab (index 1) 99 | assert_eq!( 100 | harness.state().tab_manager.get_current_tab_index(), 101 | 1, 102 | "Should be on the second tab after pressing ']' twice from the last tab" 103 | ); 104 | 105 | // Go back three tabs (wrapping around) 106 | harness.key_press(Key::OpenBracket); 107 | harness.step(); 108 | harness.key_press(Key::OpenBracket); 109 | harness.step(); 110 | harness.key_press(Key::OpenBracket); 111 | harness.step(); 112 | harness.key_press(Key::OpenBracket); 113 | harness.step(); 114 | 115 | // Should now be on the second tab again (index 1) 116 | assert_eq!( 117 | harness.state().tab_manager.get_current_tab_index(), 118 | 1, 119 | "Should be back on the second tab after pressing [ three times" 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /tests/ui_exit_popup_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use kiorg::ui::popup::PopupType; 6 | use tempfile::tempdir; 7 | use ui_test_helpers::create_harness; 8 | 9 | /// Test that the exit popup sets `shutdown_requested` to true when confirmed with Enter key 10 | #[test] 11 | fn test_exit_popup_enter_key() { 12 | // Create a temporary directory for testing 13 | let temp_dir = tempdir().unwrap(); 14 | let mut harness = create_harness(&temp_dir); 15 | 16 | // Initially, the app should not be in shutdown state 17 | assert!( 18 | !harness.state().shutdown_requested, 19 | "App should not be in shutdown state initially" 20 | ); 21 | 22 | // Press 'q' to request exit (shows exit popup) 23 | harness.key_press(Key::Q); 24 | harness.step(); 25 | 26 | // Verify exit popup is shown 27 | assert_eq!( 28 | harness.state().show_popup, 29 | Some(PopupType::Exit), 30 | "Exit popup should be shown after pressing 'q'" 31 | ); 32 | 33 | // Press Enter to confirm exit 34 | harness.key_press(Key::Enter); 35 | harness.step(); 36 | 37 | // Verify shutdown was requested 38 | assert!( 39 | harness.state().shutdown_requested, 40 | "App should be in shutdown state after confirming exit" 41 | ); 42 | } 43 | 44 | /// Test that the exit popup does not set `shutdown_requested` to true when canceled with Escape key 45 | #[test] 46 | fn test_exit_popup_escape_key() { 47 | // Create a temporary directory for testing 48 | let temp_dir = tempdir().unwrap(); 49 | let mut harness = create_harness(&temp_dir); 50 | 51 | // Initially, the app should not be in shutdown state 52 | assert!( 53 | !harness.state().shutdown_requested, 54 | "App should not be in shutdown state initially" 55 | ); 56 | 57 | // Press 'q' to request exit (shows exit popup) 58 | harness.key_press(Key::Q); 59 | harness.step(); 60 | 61 | // Verify exit popup is shown 62 | assert_eq!( 63 | harness.state().show_popup, 64 | Some(PopupType::Exit), 65 | "Exit popup should be shown after pressing 'q'" 66 | ); 67 | 68 | // Press Escape to cancel exit 69 | harness.key_press(Key::Escape); 70 | harness.step(); 71 | 72 | // Verify popup is closed 73 | assert_eq!( 74 | harness.state().show_popup, 75 | None, 76 | "Exit popup should be closed after pressing Escape" 77 | ); 78 | 79 | // Verify shutdown was not requested 80 | assert!( 81 | !harness.state().shutdown_requested, 82 | "App should not be in shutdown state after canceling exit" 83 | ); 84 | } 85 | 86 | /// Test that the exit popup does not set `shutdown_requested` to true when canceled with 'q' key 87 | #[test] 88 | fn test_exit_popup_q_key() { 89 | // Create a temporary directory for testing 90 | let temp_dir = tempdir().unwrap(); 91 | let mut harness = create_harness(&temp_dir); 92 | 93 | // Initially, the app should not be in shutdown state 94 | assert!( 95 | !harness.state().shutdown_requested, 96 | "App should not be in shutdown state initially" 97 | ); 98 | 99 | // Press 'q' to request exit (shows exit popup) 100 | harness.key_press(Key::Q); 101 | harness.step(); 102 | 103 | // Verify exit popup is shown 104 | assert_eq!( 105 | harness.state().show_popup, 106 | Some(PopupType::Exit), 107 | "Exit popup should be shown after pressing 'q'" 108 | ); 109 | 110 | // Press 'q' again to cancel exit 111 | harness.key_press(Key::Q); 112 | harness.step(); 113 | 114 | // Verify popup is closed 115 | assert_eq!( 116 | harness.state().show_popup, 117 | None, 118 | "Exit popup should be closed after pressing 'q'" 119 | ); 120 | 121 | // Verify shutdown was not requested 122 | assert!( 123 | !harness.state().shutdown_requested, 124 | "App should not be in shutdown state after canceling exit" 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/preview/plugin.rs: -------------------------------------------------------------------------------- 1 | use crate::config::colors::AppColors; 2 | use crate::ui::preview; 3 | use egui::{RichText, Ui}; 4 | 5 | pub fn render( 6 | ui: &mut Ui, 7 | components: &[kiorg_plugin::Component], 8 | colors: &AppColors, 9 | available_width: f32, 10 | ) { 11 | ui.vertical(|ui| { 12 | for component in components { 13 | match component { 14 | kiorg_plugin::Component::Title(title) => { 15 | ui.heading(RichText::new(&title.text).color(colors.fg)); 16 | } 17 | kiorg_plugin::Component::Text(text) => { 18 | preview::text::render(ui, &text.text, colors); 19 | } 20 | kiorg_plugin::Component::Image(image) => match &image.source { 21 | kiorg_plugin::ImageSource::Path(path) => { 22 | let uri = format!("file://{}", path); 23 | ui.add(egui::Image::new(uri).max_width(available_width)); 24 | } 25 | kiorg_plugin::ImageSource::Url(url) => { 26 | ui.add(egui::Image::new(url).max_width(available_width)); 27 | } 28 | kiorg_plugin::ImageSource::Bytes { format: _, data } => { 29 | use std::collections::hash_map::DefaultHasher; 30 | use std::hash::{Hash, Hasher}; 31 | let mut hasher = DefaultHasher::new(); 32 | data.hash(&mut hasher); 33 | let hash = hasher.finish(); 34 | let uri = format!("bytes://{}", hash); 35 | ui.add( 36 | egui::Image::from_bytes(uri, data.clone()).max_width(available_width), 37 | ); 38 | } 39 | }, 40 | kiorg_plugin::Component::Table(table) => { 41 | use egui_extras::{Column, TableBuilder}; 42 | let num_columns = if let Some(headers) = &table.headers { 43 | headers.len() 44 | } else if let Some(first_row) = table.rows.first() { 45 | first_row.len() 46 | } else { 47 | 0 48 | }; 49 | if num_columns == 0 { 50 | continue; 51 | } 52 | 53 | let mut builder = TableBuilder::new(ui).striped(true).vscroll(false); 54 | for _ in 0..(num_columns - 1) { 55 | builder = builder 56 | .column(Column::auto_with_initial_suggestion(150.0).resizable(true)); 57 | } 58 | builder = builder.column(Column::remainder()); 59 | 60 | let body_cb = |mut body: egui_extras::TableBody| { 61 | for row in &table.rows { 62 | body.row(18.0, |mut row_ui| { 63 | for cell in row { 64 | row_ui.col(|ui| { 65 | ui.label(RichText::new(cell).color(colors.fg)); 66 | }); 67 | } 68 | }); 69 | } 70 | }; 71 | 72 | if let Some(headers) = &table.headers { 73 | builder 74 | .header(20.0, |mut header| { 75 | for h in headers { 76 | header.col(|ui| { 77 | ui.strong(RichText::new(h).color(colors.fg)); 78 | }); 79 | } 80 | }) 81 | .body(body_cb); 82 | } else { 83 | builder.body(body_cb); 84 | } 85 | } 86 | } 87 | ui.add_space(10.0); 88 | } 89 | }); 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/clean_disk_space.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # copyed from: 5 | # https://github.com/apache/flink/blob/master/tools/azure-pipelines/free_disk_space.sh 6 | 7 | echo "==============================================================================" 8 | echo "Freeing up disk space on CI system (with parallel optimization)" 9 | echo "==============================================================================" 10 | 11 | # Function to log with timestamp 12 | log() { 13 | echo "[$(date '+%H:%M:%S')] $*" 14 | } 15 | 16 | # Function to remove packages sequentially (APT lock prevents parallel execution) 17 | remove_packages_sequential() { 18 | local patterns=("$@") 19 | 20 | # Show largest packages for reference 21 | log "Listing 20 largest packages for reference:" 22 | dpkg-query -Wf '${Installed-Size}\t${Package}\n' 2>/dev/null | sort -n | tail -n 20 || true 23 | 24 | log "Starting sequential package removal..." 25 | 26 | # Remove packages matching patterns sequentially due to APT lock 27 | for pattern in "${patterns[@]}"; do 28 | log "Removing packages matching: $pattern" 29 | sudo apt-get remove -y "$pattern" 2>/dev/null || true 30 | done 31 | 32 | sudo apt-get autoremove -y 33 | sudo apt-get clean 34 | 35 | log "All package removals completed" 36 | } 37 | 38 | # Function to remove directories in parallel 39 | remove_directories_parallel() { 40 | local dirs=("$@") 41 | local pids=() 42 | 43 | log "Starting parallel directory removal..." 44 | 45 | for dir in "${dirs[@]}"; do 46 | if [ -d "$dir" ]; then 47 | { 48 | log "Removing directory: $dir" 49 | sudo rm -rf "$dir" 50 | log "Completed removal of: $dir" 51 | } & 52 | pids+=($!) 53 | else 54 | log "Directory not found (skipping): $dir" 55 | fi 56 | done 57 | 58 | # Wait for all directory removals to complete 59 | for pid in "${pids[@]}"; do 60 | wait "$pid" 61 | done 62 | 63 | log "All directory removals completed" 64 | } 65 | 66 | # Define package patterns to remove 67 | PACKAGE_PATTERNS=( 68 | '^dotnet-.*' 69 | '^llvm-.*' 70 | 'php.*' 71 | '^mongodb-.*' 72 | '^mysql-.*' 73 | ) 74 | 75 | # Define individual packages to remove 76 | INDIVIDUAL_PACKAGES=( 77 | "azure-cli" 78 | "google-cloud-sdk" 79 | "hhvm" 80 | "google-chrome-stable" 81 | "firefox" 82 | "powershell" 83 | "mono-devel" 84 | "libgl1-mesa-dri" 85 | ) 86 | 87 | # TODO: skip removing packages by pattern for now to avoid slowing down the pipeline by too much 88 | # # Remove packages sequentially (APT lock prevents parallel execution) 89 | # remove_packages_sequential "${PACKAGE_PATTERNS[@]}" 90 | 91 | # Define directories to remove in parallel 92 | DIRECTORIES_TO_REMOVE=( 93 | "/usr/share/dotnet" 94 | "/usr/local/graalvm" 95 | "/usr/local/.ghcup" 96 | 97 | # NOTE: uncomment the followings if more disk space is needed 98 | # they are commented out to reduce CI time 99 | 100 | # "/usr/local/lib/node_modules" 101 | # "/usr/local/share/powershell" 102 | # "/usr/local/share/chromium" 103 | # "/usr/local/lib/android" 104 | # "/opt/hostedtoolcache" 105 | # "/usr/share/swift" 106 | # "/usr/local/julia*" 107 | ) 108 | 109 | # Remove large directories in parallel using xargs 110 | log "Starting parallel directory removal using xargs..." 111 | printf '%s\n' "${DIRECTORIES_TO_REMOVE[@]}" | xargs -P 64 -I {} sudo rm -rf "{}" 112 | 113 | log "Cleanining /opt directory..." 114 | cd /opt 115 | find . -maxdepth 1 -mindepth 1 '!' -path ./containerd '!' -path ./actionarchivecache '!' -path ./runner '!' -path ./runner-cache -print0 | xargs -0 -r -P 64 -n 1 rm -rf 116 | 117 | log "All directory removals completed" 118 | 119 | log "Final disk usage:" 120 | df -h 121 | 122 | echo "==============================================================================" 123 | log "Disk cleanup completed successfully!" 124 | echo "==============================================================================" -------------------------------------------------------------------------------- /crates/kiorg/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kiorg" 3 | version = "1.3.0" 4 | edition = "2024" 5 | authors = ["houqp"] 6 | repository = "https://github.com/houqp/kiorg" 7 | description = "A hacker's file manager with VIM inspired keybind" 8 | license = "MIT" 9 | keywords = ["file-manager", "gui", "productivity", "rust", "egui"] 10 | categories = ["filesystem", "gui", "command-line-utilities"] 11 | 12 | [features] 13 | debug = [] 14 | testing = [] 15 | snapshot = ["egui_kittest/snapshot", "egui_kittest/wgpu"] 16 | 17 | [lib] 18 | name = "kiorg" 19 | path = "src/lib.rs" 20 | 21 | [[bin]] 22 | name = "kiorg" 23 | path = "src/main.rs" 24 | 25 | [dependencies] 26 | kiorg_plugin = { path = "../kiorg_plugin" } 27 | 28 | eframe = "0.33" 29 | egui = { version = "0.33", features = ["accesskit", "rayon", "color-hex"] } 30 | font-kit = "0" 31 | chrono = "0" 32 | humansize = "2.1.3" 33 | toml = "0" 34 | serde = { version = "1", features = ["derive"] } 35 | serde_json = "1" 36 | rmp-serde = "1.1" 37 | dirs = "6" 38 | open = "5" 39 | clap = { version = "4.5.1", features = ["derive"] } 40 | notify = "8" 41 | egui_extras = { version = "0.33", features = ["all_loaders", "syntect"] } 42 | 43 | rbook = { git = "https://github.com/houqp/rbook", rev = "d5b7e92c86f98cb130ff7d146cd36f15a3f4a459" } 44 | file_type = "0" 45 | tracing = "0.1" 46 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 47 | nucleo = "0.5.0" 48 | uuid = { version = "1.0", features = ["v4", "serde"] } 49 | regex = "1" 50 | snafu = "0.8" 51 | zip = { version = "6", default-features = false, features = [ 52 | "aes-crypto", 53 | "bzip2", 54 | "deflate64", 55 | "deflate", 56 | "ppmd", 57 | "time", 58 | "zstd", 59 | # disable lzma and xz to avoid dynamic linking to system liblzma 60 | # "lzma", 61 | # "xz", 62 | ] } 63 | 64 | self_update = { version = "0.42", features = ["rustls"] } 65 | self-replace = "1" 66 | tempfile = "3" 67 | ureq = "2" 68 | semver = "1.0" 69 | 70 | # code highlight 71 | yazi-prebuilt = { version = "0.1.0" } 72 | syntect = { version = "5", default-features = false, features = [ 73 | "parsing", 74 | "plist-load", 75 | "regex-onig", 76 | ] } 77 | 78 | # tar handling 79 | tar = "0.4" 80 | # maximum performance while still benefiting from a Rust implementation at the cost of some unsafe 81 | flate2 = { version = "1", features = ["zlib-rs"], default-features = false } 82 | bzip2 = "0.6" 83 | 84 | # image handling 85 | image = { version = "0" } 86 | image-extras = { git = "https://github.com/image-rs/image-extras.git", rev = "fbf3e82f9646cd63e5e6e9dc0555bb781fc5dcd4" } 87 | kamadak-exif = "0" 88 | 89 | # for pdf rendering 90 | pdf_render = { git = "https://github.com/houqp/pdf_render.git", rev = "00b907936e45d904f958197fa6039320d0ac098d", features = [ 91 | "embed", 92 | ] } 93 | pdf = { git = "https://github.com/pdf-rs/pdf" } 94 | pathfinder_geometry = { git = "https://github.com/houqp/pathfinder", rev = "c82663778404c74c0d2cceb42a99b41f6e48e1e4" } 95 | pathfinder_export = { git = "https://github.com/houqp/pathfinder", rev = "c82663778404c74c0d2cceb42a99b41f6e48e1e4" } 96 | # pathfinder_rasterize = { git = "https://github.com/s3bk/pathfinder_rasterizer" } 97 | 98 | egui_nerdfonts = { git = "https://github.com/houqp/egui_nerdfonts", rev = "6f540170520692ccf1a7d89aee94aea61c89da07" } 99 | 100 | [target.'cfg(not(target_os = "windows"))'.dependencies] 101 | egui_term = { git = "https://github.com/houqp/egui_term.git", rev = "18b9fa7524b98c6330885b65040ec5b4fd43d13f" } 102 | 103 | [target.'cfg(target_os = "windows")'.dependencies] 104 | windows-sys = { version = "0.61.2", features = [ 105 | "Win32_Foundation", 106 | "Win32_Storage_FileSystem", 107 | ] } 108 | 109 | [[bench]] 110 | name = "center_panel_bench" 111 | harness = false 112 | path = "benches/center_panel_bench.rs" 113 | 114 | [[bench]] 115 | name = "top_banner_bench" 116 | harness = false 117 | path = "benches/top_banner_bench.rs" 118 | 119 | [dev-dependencies] 120 | criterion = "0.5" 121 | egui_kittest = { version = "0.33", features = ["eframe"] } 122 | kiorg = { path = ".", features = ["testing"] } 123 | windows-sys = { version = "0.61.0", features = [ 124 | "Win32_Foundation", 125 | "Win32_Storage_FileSystem", 126 | ] } 127 | 128 | [package.metadata.bundle] 129 | name = "Kiorg" 130 | icon = ["../../assets/icons/1024x1024@2x.png"] 131 | osx_url_schemes = ["com.kiorg.kiorg"] 132 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/left_panel.rs: -------------------------------------------------------------------------------- 1 | use egui::Ui; 2 | use std::path::PathBuf; 3 | 4 | use crate::app::Kiorg; 5 | use crate::ui::file_list::{self, ROW_HEIGHT}; 6 | use crate::ui::style::HEADER_ROW_HEIGHT; 7 | 8 | use super::style::section_title_text; 9 | 10 | /// Draws the left panel (parent directory list). 11 | /// Returns Some(PathBuf) if a directory was clicked for navigation. 12 | pub fn draw(app: &mut Kiorg, ui: &mut Ui, width: f32, height: f32) -> Option { 13 | let tab = app.tab_manager.current_tab_ref(); 14 | let parent_entries = &tab.parent_entries; 15 | let parent_selected_index = tab.parent_selected_index; 16 | let colors = &app.colors; 17 | let bookmarks = &app.bookmarks; 18 | 19 | let mut path_to_navigate = None; 20 | 21 | ui.vertical(|ui| { 22 | ui.set_min_width(width); 23 | ui.set_max_width(width); 24 | ui.set_min_height(height); 25 | ui.label(section_title_text("Parent Directory", colors)); 26 | ui.separator(); 27 | 28 | // Calculate available height for scroll area 29 | let available_height = height - HEADER_ROW_HEIGHT; 30 | 31 | egui::ScrollArea::vertical() 32 | .id_salt("parent_list_scroll") 33 | .auto_shrink([false; 2]) 34 | .max_height(available_height) 35 | // TODO: use show_row as an optimization 36 | .show(ui, |ui| { 37 | // Set the width of the content area 38 | let scrollbar_width = 6.0; 39 | ui.set_min_width(width - scrollbar_width); 40 | ui.set_max_width(width - scrollbar_width); 41 | 42 | // Draw all rows 43 | for (i, entry) in parent_entries.iter().enumerate() { 44 | let is_bookmarked = bookmarks.contains(&entry.path); 45 | // Check if this entry is in the clipboard as a cut or copy operation 46 | let (is_in_cut_clipboard, is_in_copy_clipboard) = match &app.clipboard { 47 | Some(crate::app::Clipboard::Cut(paths)) => { 48 | if paths.contains(&entry.path) { 49 | (true, false) 50 | } else { 51 | (false, false) 52 | } 53 | } 54 | Some(crate::app::Clipboard::Copy(paths)) => { 55 | if paths.contains(&entry.path) { 56 | (false, true) 57 | } else { 58 | (false, false) 59 | } 60 | } 61 | None => (false, false), 62 | }; 63 | let response = file_list::draw_parent_entry_row( 64 | ui, 65 | entry, 66 | i == parent_selected_index, 67 | colors, 68 | is_bookmarked, 69 | is_in_cut_clipboard, 70 | is_in_copy_clipboard, 71 | ); 72 | if response.clicked() { 73 | path_to_navigate = Some(entry.path.clone()); 74 | } 75 | 76 | // Also navigate on double-click 77 | if response.double_clicked() { 78 | path_to_navigate = Some(entry.path.clone()); 79 | } 80 | } 81 | 82 | // Ensure current directory is visible in parent list 83 | if app.scroll_left_panel && !parent_entries.is_empty() { 84 | let ui_spacing = ui.spacing().item_spacing.y; 85 | let spaced_row_height = ROW_HEIGHT + ui_spacing; 86 | let selected_pos = parent_selected_index as f32 * spaced_row_height; 87 | ui.scroll_to_rect( 88 | egui::Rect::from_min_size( 89 | egui::pos2(0.0, selected_pos), 90 | egui::vec2(width, ROW_HEIGHT), 91 | ), 92 | Some(egui::Align::Center), 93 | ); 94 | app.scroll_left_panel = false; 95 | } 96 | }); 97 | }); 98 | 99 | path_to_navigate 100 | } 101 | -------------------------------------------------------------------------------- /tests/ui_bulk_delete_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use kiorg::ui::popup::PopupType; 6 | use tempfile::tempdir; 7 | use ui_test_helpers::{create_harness, create_test_files, wait_for_condition}; 8 | 9 | #[test] 10 | fn test_bulk_delete_with_space_key() { 11 | // Create a temporary directory for testing 12 | let temp_dir = tempdir().unwrap(); 13 | let test_files = create_test_files(&[ 14 | temp_dir.path().join("file1.txt"), 15 | temp_dir.path().join("file2.txt"), 16 | temp_dir.path().join("file3.txt"), 17 | temp_dir.path().join("file4.txt"), 18 | ]); 19 | 20 | let mut harness = create_harness(&temp_dir); 21 | 22 | // Initially, no files should be selected (marked) 23 | { 24 | let tab = harness.state().tab_manager.current_tab_ref(); 25 | assert!( 26 | tab.marked_entries.is_empty(), 27 | "No entries should be selected initially" 28 | ); 29 | } 30 | 31 | // Select the first file using space 32 | harness.key_press(Key::Space); 33 | harness.step(); 34 | 35 | // Move to the second file 36 | harness.key_press(Key::J); 37 | harness.step(); 38 | 39 | // Select the second file 40 | harness.key_press(Key::Space); 41 | harness.step(); 42 | 43 | // Verify both first and second files are selected 44 | { 45 | let tab = harness.state().tab_manager.current_tab_ref(); 46 | assert!( 47 | tab.marked_entries.contains(&test_files[0]), 48 | "First entry should be selected" 49 | ); 50 | assert!( 51 | tab.marked_entries.contains(&test_files[1]), 52 | "Second entry should be selected" 53 | ); 54 | } 55 | 56 | // Press delete key to trigger bulk deletion 57 | harness.key_press(Key::D); 58 | harness.step(); 59 | 60 | // Verify the delete popup is shown with multiple entries 61 | { 62 | let app = harness.state(); 63 | assert!( 64 | matches!(app.show_popup, Some(PopupType::Delete(_, _))), 65 | "Delete popup should be shown" 66 | ); 67 | if let Some(PopupType::Delete(_, entries)) = &app.show_popup { 68 | assert_eq!( 69 | entries.len(), 70 | 2, 71 | "Two entries should be marked for deletion" 72 | ); 73 | } 74 | } 75 | 76 | // Press Enter for first confirmation 77 | harness.key_press(Key::Enter); 78 | harness.step(); 79 | 80 | // Verify we're now in the recursive confirmation state (second confirmation required) 81 | { 82 | let app = harness.state(); 83 | if let Some(PopupType::Delete(state, _)) = &app.show_popup { 84 | assert_eq!( 85 | *state, 86 | kiorg::ui::popup::delete::DeleteConfirmState::RecursiveConfirm, 87 | "Should be in recursive confirmation state after first Enter" 88 | ); 89 | } else { 90 | panic!("Expected Delete popup to be open"); 91 | } 92 | 93 | // Verify files still exist after first confirmation 94 | assert!( 95 | test_files[0].exists(), 96 | "file1.txt should still exist after first confirmation" 97 | ); 98 | assert!( 99 | test_files[1].exists(), 100 | "file2.txt should still exist after first confirmation" 101 | ); 102 | } 103 | 104 | // Press Enter for second confirmation 105 | harness.key_press(Key::Enter); 106 | 107 | wait_for_condition(|| { 108 | harness.step(); 109 | harness.state().show_popup.is_none() 110 | }); 111 | 112 | // Verify the files are deleted after second confirmation 113 | { 114 | let tab = harness.state().tab_manager.current_tab_ref(); 115 | assert!( 116 | tab.marked_entries.is_empty(), 117 | "Marked entries should be cleared after deletion" 118 | ); 119 | 120 | // Check that the files are actually deleted from the file system 121 | assert!(!test_files[0].exists(), "file1.txt should be deleted"); 122 | assert!(!test_files[1].exists(), "file2.txt should be deleted"); 123 | assert!(test_files[2].exists(), "file3.txt should still exist"); 124 | assert!(test_files[3].exists(), "file4.txt should still exist"); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/top_banner.rs: -------------------------------------------------------------------------------- 1 | use crate::app::Kiorg; 2 | use crate::ui::popup::PopupType; 3 | use crate::ui::{path_nav, update}; 4 | use egui::{RichText, Ui}; 5 | 6 | pub fn draw(app: &mut Kiorg, ui: &mut Ui) { 7 | ui.vertical(|ui| { 8 | ui.horizontal(|ui| { 9 | let tab_indexes = app.tab_manager.tab_indexes(); 10 | 11 | // Path navigation on the left 12 | ui.with_layout(egui::Layout::left_to_right(egui::Align::LEFT), |ui| { 13 | if let Some(message) = path_nav::draw_path_navigation( 14 | ui, 15 | &app.tab_manager.current_tab_mut().current_path, 16 | &app.colors, 17 | tab_indexes.len(), 18 | ) { 19 | match message { 20 | path_nav::PathNavMessage::Navigate(path) => { 21 | app.navigate_to_dir(path); 22 | } 23 | } 24 | } 25 | }); 26 | 27 | // Tab numbers on the right 28 | ui.with_layout(egui::Layout::right_to_left(egui::Align::RIGHT), |ui| { 29 | // Menu button with popup 30 | ui.menu_button(RichText::new("☰").color(app.colors.fg_light), |ui| { 31 | ui.set_min_width(150.0); 32 | 33 | if ui.button("Bookmarks").clicked() { 34 | app.show_popup = Some(PopupType::Bookmarks(0)); 35 | ui.close(); 36 | } 37 | 38 | #[cfg(target_os = "windows")] 39 | if ui.button("Drives").clicked() { 40 | app.show_popup = Some(PopupType::WindowsDrives(0)); 41 | ui.close(); 42 | } 43 | 44 | #[cfg(target_os = "macos")] 45 | if ui.button("Volumes").clicked() { 46 | app.show_popup = Some(PopupType::Volumes(0)); 47 | ui.close(); 48 | } 49 | 50 | if ui.button("Themes").clicked() { 51 | // Use current theme key or default to dark_kiorg 52 | let current_theme_key = app 53 | .config 54 | .theme 55 | .clone() 56 | .unwrap_or_else(|| "dark_kiorg".to_string()); 57 | app.show_popup = Some(PopupType::Themes(current_theme_key)); 58 | ui.close(); 59 | } 60 | 61 | if ui.button("Plugins").clicked() { 62 | app.show_popup = Some(PopupType::Plugins); 63 | ui.close(); 64 | } 65 | 66 | if ui.button("Check for update").clicked() { 67 | update::check_for_updates(app); 68 | ui.close(); 69 | } 70 | 71 | ui.separator(); 72 | 73 | if ui.button("Help").clicked() { 74 | app.show_popup = Some(PopupType::Help); 75 | ui.close(); 76 | } 77 | 78 | if ui.button("About").clicked() { 79 | app.show_popup = Some(PopupType::About); 80 | ui.close(); 81 | } 82 | 83 | ui.separator(); 84 | 85 | if ui.button("Exit").clicked() { 86 | app.show_popup = Some(PopupType::Exit); 87 | ui.close(); 88 | } 89 | }); 90 | 91 | // Add some spacing between menu and tabs 92 | ui.add_space(5.0); 93 | 94 | // Tab numbers 95 | for (i, is_current) in tab_indexes.into_iter().rev() { 96 | let text = format!("{}", i + 1); 97 | let color = if is_current { 98 | app.colors.highlight 99 | } else { 100 | app.colors.link_text 101 | }; 102 | if ui.link(RichText::new(text).color(color)).clicked() { 103 | app.tab_manager.switch_to_tab(i); 104 | app.refresh_entries(); 105 | } 106 | } 107 | }); 108 | }); 109 | ui.separator(); 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/terminal.rs: -------------------------------------------------------------------------------- 1 | use crate::app::Kiorg; 2 | 3 | #[cfg(not(target_os = "windows"))] 4 | mod implementation { 5 | use super::Kiorg; 6 | use crate::ui::style::section_title_text; 7 | use egui::Vec2; 8 | use egui_term::{PtyEvent, TerminalView}; 9 | 10 | pub struct TerminalContext { 11 | pub terminal_backend: egui_term::TerminalBackend, 12 | pub pty_proxy_receiver: std::sync::mpsc::Receiver<(u64, egui_term::PtyEvent)>, 13 | } 14 | 15 | impl TerminalContext { 16 | pub fn new( 17 | ctx: &egui::Context, 18 | working_directory: std::path::PathBuf, 19 | ) -> Result { 20 | let system_shell = std::env::var("SHELL") 21 | .map_err(|e| format!("SHELL variable is not defined: {e}"))?; 22 | 23 | // Sometimes, TERM is not set properly on app start, e.g. launching from MacOS Dock 24 | if std::env::var("TERM").is_err() { 25 | unsafe { 26 | std::env::set_var("TERM", "xterm-256color"); 27 | } 28 | } 29 | 30 | let (pty_proxy_sender, pty_proxy_receiver) = std::sync::mpsc::channel(); 31 | 32 | let terminal_backend = egui_term::TerminalBackend::new( 33 | 0, 34 | ctx.clone(), 35 | pty_proxy_sender, 36 | egui_term::BackendSettings { 37 | shell: system_shell, 38 | working_directory: Some(working_directory), 39 | ..Default::default() 40 | }, 41 | ) 42 | .map_err(|e| format!("Failed to create terminal backend: {e}"))?; 43 | 44 | Ok(Self { 45 | terminal_backend, 46 | pty_proxy_receiver, 47 | }) 48 | } 49 | } 50 | 51 | pub fn draw(ctx: &egui::Context, app: &mut Kiorg) { 52 | if let Some(terminal_ctx) = &mut app.terminal_ctx { 53 | if let Ok((_, PtyEvent::Exit)) = terminal_ctx.pty_proxy_receiver.try_recv() { 54 | app.terminal_ctx = None; 55 | return; 56 | } 57 | 58 | let mut close_terminal = false; 59 | 60 | // Create a panel at the bottom of the screen 61 | let screen_height = ctx.content_rect().height(); 62 | egui::TopBottomPanel::bottom("terminal_panel") 63 | .resizable(true) 64 | .default_height(screen_height * 0.6) 65 | .min_height(100.0) 66 | .max_height(screen_height * 0.9) 67 | .show(ctx, |ui| { 68 | // Add a close button in the top right corner 69 | ui.horizontal(|ui| { 70 | ui.label(section_title_text("Terminal", &app.colors)); 71 | ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { 72 | if ui.button("×").clicked() { 73 | close_terminal = true; 74 | } 75 | }); 76 | }); 77 | 78 | let terminal = TerminalView::new(ui, &mut terminal_ctx.terminal_backend) 79 | .set_focus(true) 80 | .set_size(Vec2::new(ui.available_width(), ui.available_height())); 81 | ui.add(terminal); 82 | }); 83 | 84 | // Close the terminal if the close button was clicked 85 | if close_terminal { 86 | app.terminal_ctx = None; 87 | } 88 | } 89 | } 90 | } 91 | 92 | #[cfg(target_os = "windows")] 93 | mod implementation { 94 | use super::*; 95 | use crate::ui::popup::PopupType; 96 | 97 | pub struct TerminalContext {} 98 | 99 | impl TerminalContext { 100 | pub fn new( 101 | _ctx: &egui::Context, 102 | _working_directory: std::path::PathBuf, 103 | ) -> Result { 104 | Ok(Self {}) 105 | } 106 | } 107 | 108 | pub fn draw(_ctx: &egui::Context, app: &mut Kiorg) { 109 | if app.terminal_ctx.is_some() { 110 | // Show the feature disabled popup 111 | app.show_popup = Some(PopupType::GenericMessage( 112 | "Terminal feature disabled".to_string(), 113 | "Terminal feature disabled for this release".to_string(), 114 | )); 115 | app.terminal_ctx = None; 116 | } 117 | } 118 | } 119 | 120 | pub use implementation::{TerminalContext, draw}; 121 | -------------------------------------------------------------------------------- /.ai_instructions.md: -------------------------------------------------------------------------------- 1 | # Project Guidelines 2 | 3 | ## Code Style & Patterns 4 | 5 | - Prefer composition over inheritance 6 | - Always run `cargo clippy --all-targets --all-features -- -D warnings` on every change to make sure there is no warning. 7 | - Break code into modules logically instead of keeping all of them into large files. 8 | - Avoid deeply nested code structure by breaking up implementation into pure functions. 9 | 10 | ## Testing Standards 11 | 12 | - Write new tests whenever applicable to prevent regressions. 13 | - Prefer integration tests in `tests` folder over unit tests for more complex test cases. 14 | 15 | ## AI's Memory Bank 16 | 17 | I am an expert hacker with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional. 18 | 19 | ### Memory Bank Structure 20 | 21 | The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy: 22 | 23 | flowchart TD 24 | PB[projectbrief.md] --> PC[productContext.md] 25 | PB --> SP[systemPatterns.md] 26 | PB --> TC[techContext.md] 27 | 28 | PC --> AC[activeContext.md] 29 | SP --> AC 30 | TC --> AC 31 | 32 | AC --> P[progress.md] 33 | 34 | #### Core Files (Required) 35 | 1. `projectbrief.md` 36 | - Foundation document that shapes all other files 37 | - Created at project start if it doesn't exist 38 | - Defines core requirements and goals 39 | - Source of truth for project scope 40 | 41 | 2. `productContext.md` 42 | - Why this project exists 43 | - Problems it solves 44 | - How it should work 45 | - User experience goals 46 | 47 | 3. `activeContext.md` 48 | - Current work focus 49 | - Recent changes 50 | - Next steps 51 | - Active decisions and considerations 52 | - Important patterns and preferences 53 | - Learnings and project insights 54 | 55 | 4. `systemPatterns.md` 56 | - System architecture 57 | - Key technical decisions 58 | - Design patterns in use 59 | - Component relationships 60 | - Critical implementation paths 61 | 62 | 5. `techContext.md` 63 | - Technologies used 64 | - Development setup 65 | - Technical constraints 66 | - Dependencies 67 | - Tool usage patterns 68 | 69 | 6. `progress.md` 70 | - What works 71 | - What's left to build 72 | - Current status 73 | - Known issues 74 | - Evolution of project decisions 75 | 76 | #### Additional Context 77 | Create additional files/folders within memory-bank/ when they help organize: 78 | - Complex feature documentation 79 | - Integration specifications 80 | - API documentation 81 | - Testing strategies 82 | - Deployment procedures 83 | 84 | ### Core Workflows 85 | 86 | #### Plan Mode 87 | flowchart TD 88 | Start[Start] --> ReadFiles[Read Memory Bank] 89 | ReadFiles --> CheckFiles{Files Complete?} 90 | 91 | CheckFiles -->|No| Plan[Create Plan] 92 | Plan --> Document[Document in Chat] 93 | 94 | CheckFiles -->|Yes| Verify[Verify Context] 95 | Verify --> Strategy[Develop Strategy] 96 | Strategy --> Present[Present Approach] 97 | 98 | #### Act Mode 99 | flowchart TD 100 | Start[Start] --> Context[Check Memory Bank] 101 | Context --> Update[Update Documentation] 102 | Update --> Execute[Execute Task] 103 | Execute --> Document[Document Changes] 104 | 105 | ### Documentation Updates 106 | 107 | Memory Bank updates occur when: 108 | 1. Discovering new project patterns 109 | 2. After implementing significant changes 110 | 3. When user requests with **update memory bank** (MUST review ALL files) 111 | 4. When context needs clarification 112 | 113 | flowchart TD 114 | Start[Update Process] 115 | 116 | subgraph Process 117 | P1[Review ALL Files] 118 | P2[Document Current State] 119 | P3[Clarify Next Steps] 120 | P4[Document Insights & Patterns] 121 | 122 | P1 --> P2 --> P3 --> P4 123 | end 124 | 125 | Start --> Process 126 | 127 | Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state. 128 | 129 | REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy. -------------------------------------------------------------------------------- /tests/ui_rename_popup_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use kiorg::ui::popup::PopupType; 6 | use tempfile::tempdir; 7 | use ui_test_helpers::{create_harness, create_test_files}; 8 | 9 | /// Test that the rename popup works correctly 10 | #[test] 11 | fn test_rename_popup() { 12 | // Create a temporary directory for testing 13 | let temp_dir = tempdir().unwrap(); 14 | 15 | // Create test files 16 | let test_files = create_test_files(&[ 17 | temp_dir.path().join("file1.txt"), 18 | temp_dir.path().join("file2.txt"), 19 | temp_dir.path().join("file3.txt"), 20 | ]); 21 | 22 | let mut harness = create_harness(&temp_dir); 23 | 24 | // Move down to select file2.txt (index 1) 25 | harness.key_press(Key::J); 26 | harness.step(); 27 | 28 | // Verify initial selection 29 | assert_eq!( 30 | harness.state().tab_manager.current_tab_ref().selected_index, 31 | 1, 32 | "Initial selection should be at index 1 (file2.txt)" 33 | ); 34 | 35 | // Press 'r' to start renaming 36 | harness.key_press(Key::R); 37 | harness.step(); 38 | 39 | // Verify the rename popup is shown with the correct filename 40 | if let Some(PopupType::Rename(name)) = &harness.state().show_popup { 41 | assert_eq!( 42 | name, "file2.txt", 43 | "Rename popup should contain the current filename" 44 | ); 45 | } else { 46 | panic!("Rename popup should be open with the filename"); 47 | } 48 | 49 | // Simulate text input for the new name 50 | harness 51 | .input_mut() 52 | .events 53 | .push(egui::Event::Text("file2_renamed".to_string())); 54 | harness.step(); 55 | 56 | // Press Enter to confirm rename 57 | harness.key_press(Key::Enter); 58 | harness.step(); 59 | 60 | // Verify the popup is closed 61 | assert_eq!( 62 | harness.state().show_popup, 63 | None, 64 | "Rename popup should be closed after confirming" 65 | ); 66 | 67 | // Verify the file was renamed 68 | assert!(test_files[0].exists(), "file1.txt should still exist"); 69 | assert!(!test_files[1].exists(), "file2.txt should no longer exist"); 70 | assert!( 71 | temp_dir.path().join("file2_renamed.txt").exists(), 72 | "file2_renamed.txt should exist" 73 | ); 74 | 75 | // Verify UI list is updated 76 | { 77 | let tab = harness.state().tab_manager.current_tab_ref(); 78 | assert!( 79 | !tab.entries.iter().any(|e| e.name == "file2.txt"), 80 | "UI entry list should not contain file2.txt after rename" 81 | ); 82 | assert!( 83 | tab.entries.iter().any(|e| e.name == "file2_renamed.txt"), 84 | "UI entry list should contain file2_renamed.txt after rename" 85 | ); 86 | } 87 | 88 | // Test canceling the rename popup 89 | // Select file3.txt 90 | harness.key_press(Key::J); 91 | harness.step(); 92 | 93 | // Press 'r' to start renaming 94 | harness.key_press(Key::R); 95 | harness.step(); 96 | 97 | // Verify the rename popup is shown with the correct filename 98 | if let Some(PopupType::Rename(name)) = &harness.state().show_popup { 99 | assert_eq!( 100 | name, "file3.txt", 101 | "Rename popup should contain the current filename" 102 | ); 103 | } else { 104 | panic!("Rename popup should be open with the filename"); 105 | } 106 | 107 | // Simulate text input for the new name 108 | harness 109 | .input_mut() 110 | .events 111 | .push(egui::Event::Text("_should_not_rename.txt".to_string())); 112 | harness.step(); 113 | 114 | // Press Escape to cancel rename 115 | harness.key_press(Key::Escape); 116 | harness.step(); 117 | 118 | // Verify the popup is closed 119 | assert_eq!( 120 | harness.state().show_popup, 121 | None, 122 | "Rename popup should be closed after canceling" 123 | ); 124 | 125 | // Verify the file was NOT renamed 126 | assert!(test_files[2].exists(), "file3.txt should still exist"); 127 | assert!( 128 | !temp_dir.path().join("file3_should_not_rename.txt").exists(), 129 | "file3_should_not_rename.txt should NOT exist" 130 | ); 131 | 132 | // Verify UI list is not changed 133 | { 134 | let tab = harness.state().tab_manager.current_tab_ref(); 135 | assert!( 136 | tab.entries.iter().any(|e| e.name == "file3.txt"), 137 | "UI entry list should still contain file3.txt after canceled rename" 138 | ); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /crates/kiorg/src/font.rs: -------------------------------------------------------------------------------- 1 | // Take from https://github.com/emilk/egui/discussions/1344#discussioncomment-11919481 2 | 3 | use std::collections::HashMap; 4 | use std::fs::read; 5 | use std::sync::Arc; 6 | 7 | use eframe::epaint::text::FontFamily; 8 | use egui::{FontData, FontDefinitions}; 9 | use font_kit::{ 10 | family_name::FamilyName, handle::Handle, properties::Properties, source::SystemSource, 11 | }; 12 | use tracing::debug; 13 | 14 | /// Attempt to load a system font by any of the given `family_names`, returning the first match. 15 | fn load_font_family(family_names: &[&str]) -> Option> { 16 | let system_source = SystemSource::new(); 17 | for &name in family_names { 18 | let font_handle = system_source 19 | .select_best_match(&[FamilyName::Title(name.to_string())], &Properties::new()); 20 | match font_handle { 21 | Ok(h) => match &h { 22 | Handle::Memory { bytes, .. } => { 23 | debug!("Loaded {name} from memory."); 24 | return Some(bytes.to_vec()); 25 | } 26 | Handle::Path { path, .. } => { 27 | debug!("Loaded {name} from path: {:?}", path); 28 | if let Ok(data) = read(path) { 29 | return Some(data); 30 | } 31 | } 32 | }, 33 | Err(e) => debug!("Could not load {}: {:?}", name, e), 34 | } 35 | } 36 | None 37 | } 38 | 39 | fn load_system_fonts(mut fonts: FontDefinitions) -> FontDefinitions { 40 | debug!("Attempting to load system fonts"); 41 | let mut fontdb = HashMap::new(); 42 | 43 | // load system front to render non-breaking spaces 44 | #[cfg(target_os = "macos")] 45 | fontdb.insert("system", vec!["Lucida Grande"]); 46 | 47 | fontdb.insert( 48 | "simplified_chinese", 49 | vec![ 50 | "Heiti SC", 51 | "Songti SC", 52 | "Noto Sans CJK SC", // Good coverage for Simplified Chinese 53 | "Noto Sans SC", 54 | "WenQuanYi Zen Hei", // INcludes both Simplified and Traditional Chinese. 55 | "SimSun", 56 | "Noto Sans SC", 57 | "PingFang SC", 58 | "Source Han Sans CN", 59 | ], 60 | ); 61 | 62 | fontdb.insert("traditional_chinese", vec!["Source Han Sans HK"]); 63 | 64 | fontdb.insert( 65 | "japanese", 66 | vec![ 67 | "Noto Sans JP", 68 | "Noto Sans CJK JP", 69 | "Source Han Sans JP", 70 | "MS Gothic", 71 | ], 72 | ); 73 | 74 | fontdb.insert("korean", vec!["Source Han Sans KR"]); 75 | 76 | fontdb.insert("taiwanese", vec!["Source Han Sans TW"]); 77 | 78 | fontdb.insert( 79 | "arabic_fonts", 80 | vec![ 81 | "Noto Sans Arabic", 82 | "Amiri", 83 | "Lateef", 84 | "Al Tarikh", 85 | "Segoe UI", 86 | ], 87 | ); 88 | 89 | for (region, font_names) in fontdb { 90 | if let Some(font_data) = load_font_family(&font_names) { 91 | debug!("Inserting font {region}"); 92 | fonts 93 | .font_data 94 | .insert(region.to_owned(), Arc::new(FontData::from_owned(font_data))); 95 | 96 | fonts 97 | .families 98 | .get_mut(&FontFamily::Proportional) 99 | .unwrap() 100 | .push(region.to_owned()); 101 | 102 | // Also add CJK fonts to Monospace family for better text preview support 103 | fonts 104 | .families 105 | .get_mut(&FontFamily::Monospace) 106 | .unwrap() 107 | .push(region.to_owned()); 108 | } else { 109 | debug!( 110 | "Could not load a font for region {region}. If you experience incorrect file names, try installing one of these fonts: [{}]", 111 | font_names.join(", ") 112 | ); 113 | } 114 | } 115 | 116 | fonts 117 | } 118 | 119 | /// Configure egui context with proper fonts for emoji and system font rendering 120 | /// This function should be used consistently across the application and tests 121 | pub fn configure_egui_fonts(ctx: &egui::Context) { 122 | let mut fonts = load_system_fonts(egui::FontDefinitions::default()); 123 | 124 | // Add Nerd Fonts to both Monospace and Proportional families 125 | fonts.font_data.insert( 126 | "nerdfonts".into(), 127 | egui_nerdfonts::Variant::Regular.font_data().into(), 128 | ); 129 | if let Some(font_keys) = fonts.families.get_mut(&egui::FontFamily::Monospace) { 130 | font_keys.push("nerdfonts".into()); 131 | } 132 | if let Some(font_keys) = fonts.families.get_mut(&egui::FontFamily::Proportional) { 133 | font_keys.push("nerdfonts".into()); 134 | } 135 | 136 | ctx.set_fonts(fonts); 137 | } 138 | -------------------------------------------------------------------------------- /crates/kiorg/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | use clap::Parser; 3 | use eframe::egui; 4 | use std::fs; 5 | use std::path::PathBuf; 6 | use tracing_subscriber::{EnvFilter, fmt}; 7 | 8 | use kiorg::app::Kiorg; 9 | 10 | #[derive(Parser, Debug)] 11 | #[command(author, version, about, long_about = None)] 12 | struct Args { 13 | /// Directory to open (default: use saved state or current directory) 14 | directory: Option, 15 | } 16 | 17 | fn init_tracing() { 18 | // Get log level from environment variable or use "info" as default 19 | let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { 20 | EnvFilter::new("info,font=error,pdf_render=error,eframe=error,winit=error") 21 | }); 22 | 23 | // Initialize the tracing subscriber 24 | fmt::fmt() 25 | .with_env_filter(env_filter) 26 | .with_target(true) 27 | .init(); 28 | } 29 | 30 | fn main() -> Result<(), eframe::Error> { 31 | init_tracing(); 32 | image_extras::register(); 33 | 34 | let args = Args::parse(); 35 | 36 | // If a directory is provided, validate and canonicalize it 37 | let initial_dir = if let Some(dir) = args.directory { 38 | // Validate the provided directory 39 | if !dir.exists() { 40 | return kiorg::startup_error::StartupErrorApp::show_error_dialog( 41 | format!("Directory '{}' does not exist", dir.display()), 42 | "Filesystem Error".to_string(), 43 | Some(format!("Requested directory: {}", dir.display())), 44 | ); 45 | } 46 | 47 | if !dir.is_dir() { 48 | return kiorg::startup_error::StartupErrorApp::show_error_dialog( 49 | format!("'{}' is not a directory", dir.display()), 50 | "Filesystem Error".to_string(), 51 | Some(format!("Path provided: {}", dir.display())), 52 | ); 53 | } 54 | 55 | // Canonicalize the path to get absolute path 56 | let canonical_dir = match fs::canonicalize(&dir) { 57 | Ok(path) => path, 58 | Err(e) => { 59 | return kiorg::startup_error::StartupErrorApp::show_error_dialog( 60 | format!("Failed to canonicalize path '{}': {}", dir.display(), e), 61 | "Permission Error".to_string(), 62 | Some(format!("Path provided: {}", dir.display())), 63 | ); 64 | } 65 | }; 66 | 67 | Some(canonical_dir) 68 | } else { 69 | // No directory provided, use None to load from saved state 70 | None 71 | }; 72 | 73 | // Load the app icon from embedded data 74 | let icon_data = kiorg::utils::icon::load_app_icon(); 75 | 76 | let options = eframe::NativeOptions { 77 | viewport: egui::ViewportBuilder::default() 78 | .with_inner_size([1280.0, 800.0]) 79 | .with_min_inner_size([800.0, 600.0]) 80 | .with_icon(icon_data), 81 | ..Default::default() 82 | }; 83 | 84 | eframe::run_native( 85 | "Kiorg", 86 | options, 87 | Box::new(|cc| { 88 | egui_extras::install_image_loaders(&cc.egui_ctx); 89 | 90 | // Configure fonts for proper emoji and system font rendering 91 | kiorg::font::configure_egui_fonts(&cc.egui_ctx); 92 | 93 | match Kiorg::new(cc, initial_dir) { 94 | Ok(app) => Ok(Box::new(app)), 95 | Err(e) => { 96 | // Show the error in a startup error dialog instead of exiting 97 | // Reset viewport size for error dialog 98 | cc.egui_ctx 99 | .send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::Vec2::new( 100 | 600.0, 400.0, 101 | ))); 102 | cc.egui_ctx 103 | .send_viewport_cmd(egui::ViewportCommand::MinInnerSize(egui::Vec2::new( 104 | 300.0, 250.0, 105 | ))); 106 | 107 | // Check if it's a config error to include config path in additional info 108 | let additional_info = match &e { 109 | kiorg::app::KiorgError::ConfigError(config_error) => Some(format!( 110 | "Config file: {}", 111 | config_error.config_path().display() 112 | )), 113 | _ => None, 114 | }; 115 | 116 | Ok(kiorg::startup_error::StartupErrorApp::create_error_app( 117 | cc, 118 | e.to_string(), 119 | "Application Initialization Error".to_string(), 120 | additional_info, 121 | )) 122 | } 123 | } 124 | }), 125 | ) 126 | } 127 | -------------------------------------------------------------------------------- /crates/kiorg/benches/center_panel_bench.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::let_underscore_untyped)] 2 | #![allow(dead_code)] 3 | 4 | use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; 5 | use eframe::egui; 6 | use kiorg::Kiorg; 7 | use std::fs::File; 8 | use std::path::PathBuf; 9 | use tempfile::tempdir; 10 | 11 | /// Create some test files for benchmarking 12 | fn create_test_files(base_path: &std::path::Path, count: usize) -> Vec { 13 | let mut paths = Vec::new(); 14 | 15 | // Create some directories 16 | for i in 0..count / 4 { 17 | let dir_path = base_path.join(format!("dir_{i:03}")); 18 | std::fs::create_dir(&dir_path).unwrap(); 19 | paths.push(dir_path); 20 | } 21 | 22 | // Create some files 23 | for i in 0..count { 24 | let file_path = base_path.join(format!("file_{i:03}.txt")); 25 | File::create(&file_path).unwrap(); 26 | paths.push(file_path); 27 | } 28 | 29 | paths 30 | } 31 | 32 | /// Create a Kiorg app instance for benchmarking 33 | fn create_test_app(file_count: usize) -> (Kiorg, tempfile::TempDir, tempfile::TempDir) { 34 | let temp_dir = tempdir().expect("Failed to create temp directory"); 35 | let config_temp_dir = tempdir().expect("Failed to create config temp directory"); 36 | let test_config_dir = config_temp_dir.path().to_path_buf(); 37 | 38 | // Create test files 39 | create_test_files(temp_dir.path(), file_count); 40 | 41 | // Create egui context and creation context 42 | let ctx = egui::Context::default(); 43 | let cc = eframe::CreationContext::_new_kittest(ctx); 44 | 45 | let app = Kiorg::new_with_config_dir( 46 | &cc, 47 | Some(temp_dir.path().to_path_buf()), 48 | Some(test_config_dir), 49 | ) 50 | .expect("Failed to create Kiorg app"); 51 | 52 | (app, temp_dir, config_temp_dir) 53 | } 54 | 55 | /// Benchmark the center panel draw method with different scenarios 56 | fn bench_center_panel_draw(c: &mut Criterion) { 57 | let mut group = c.benchmark_group("center_panel_draw"); 58 | group.sample_size(20); // Reduce from default 100 to 10 samples 59 | 60 | group.bench_function("baseline", |b| { 61 | b.iter_batched( 62 | || create_test_app(200), 63 | |(mut app, _temp_dir, _config_temp_dir)| { 64 | let ctx = egui::Context::default(); 65 | let input = egui::RawInput::default(); 66 | 67 | let _ = ctx.run(input, |ctx| { 68 | egui::CentralPanel::default().show(ctx, |ui| { 69 | let available_rect = ui.available_rect_before_wrap(); 70 | let width = available_rect.width(); 71 | let height = available_rect.height(); 72 | 73 | // Call the actual center_panel::draw function 74 | kiorg::ui::center_panel::draw(&mut app, ui, width, height); 75 | }); 76 | }); 77 | }, 78 | BatchSize::LargeInput, 79 | ); 80 | }); 81 | 82 | group.finish(); 83 | } 84 | 85 | /// Benchmark with search functionality 86 | fn bench_center_panel_with_search(c: &mut Criterion) { 87 | let mut group = c.benchmark_group("center_panel_search"); 88 | group.sample_size(20); 89 | 90 | group.bench_function("with_search", |b| { 91 | b.iter_batched( 92 | || { 93 | let (mut app, temp_dir, config_temp_dir) = create_test_app(200); 94 | // Set a search query to filter results 95 | app.search_bar.query = Some("file_".to_string()); // More general search to match more files 96 | // Clear selection to avoid index mismatch issues 97 | app.tab_manager.current_tab_mut().selected_index = 0; 98 | (app, temp_dir, config_temp_dir) 99 | }, 100 | |(mut app, _temp_dir, _config_temp_dir)| { 101 | let ctx = egui::Context::default(); 102 | let input = egui::RawInput::default(); 103 | 104 | let _ = ctx.run(input, |ctx| { 105 | egui::CentralPanel::default().show(ctx, |ui| { 106 | let available_rect = ui.available_rect_before_wrap(); 107 | let width = available_rect.width(); 108 | let height = available_rect.height(); 109 | 110 | // Call the actual center_panel::draw function with search active 111 | kiorg::ui::center_panel::draw(&mut app, ui, width, height); 112 | }); 113 | }); 114 | }, 115 | BatchSize::LargeInput, 116 | ); 117 | }); 118 | 119 | group.finish(); 120 | } 121 | 122 | criterion_group!( 123 | name = benches; 124 | config = Criterion::default().sample_size(10); 125 | targets = bench_center_panel_draw, bench_center_panel_with_search 126 | ); 127 | 128 | criterion_main!(benches); 129 | -------------------------------------------------------------------------------- /crates/kiorg/src/startup_error.rs: -------------------------------------------------------------------------------- 1 | use eframe::egui; 2 | 3 | /// A minimal egui app that displays startup errors 4 | pub struct StartupErrorApp { 5 | error_message: String, 6 | title: String, 7 | additional_info: Option, 8 | } 9 | 10 | impl StartupErrorApp { 11 | pub fn new(error_message: String, title: String) -> Self { 12 | Self { 13 | error_message, 14 | title, 15 | additional_info: None, 16 | } 17 | } 18 | 19 | /// Create a new startup error app with additional context information 20 | pub fn with_info(error_message: String, title: String, additional_info: String) -> Self { 21 | Self { 22 | error_message, 23 | title, 24 | additional_info: Some(additional_info), 25 | } 26 | } 27 | 28 | /// Show a startup error dialog using eframe 29 | pub fn show_error_dialog( 30 | error_message: String, 31 | title: String, 32 | additional_info: Option, 33 | ) -> Result<(), eframe::Error> { 34 | let icon_data = crate::utils::icon::load_app_icon(); 35 | let window_title = format!("Kiorg - {title}"); 36 | 37 | let options = eframe::NativeOptions { 38 | viewport: egui::ViewportBuilder::default() 39 | .with_resizable(true) 40 | .with_title(&window_title) 41 | .with_inner_size([600.0, 400.0]) 42 | .with_icon(icon_data), 43 | centered: true, 44 | ..Default::default() 45 | }; 46 | 47 | eframe::run_native( 48 | &window_title, 49 | options, 50 | Box::new(move |cc| { 51 | Ok(Self::create_error_app( 52 | cc, 53 | error_message, 54 | title, 55 | additional_info, 56 | )) 57 | }), 58 | ) 59 | } 60 | 61 | /// Create a startup error app that can be returned directly to eframe 62 | pub fn create_error_app( 63 | cc: &eframe::CreationContext<'_>, 64 | error_message: String, 65 | title: String, 66 | additional_info: Option, 67 | ) -> Box { 68 | // Set default theme for error dialog 69 | let default_theme = crate::theme::get_default_theme(); 70 | cc.egui_ctx 71 | .set_visuals(default_theme.get_colors().to_visuals()); 72 | 73 | let app = if let Some(info) = additional_info { 74 | StartupErrorApp::with_info(error_message, title, info) 75 | } else { 76 | StartupErrorApp::new(error_message, title) 77 | }; 78 | Box::new(app) 79 | } 80 | } 81 | 82 | impl eframe::App for StartupErrorApp { 83 | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 84 | egui::CentralPanel::default().show(ctx, |ui| { 85 | ui.vertical(|ui| { 86 | ui.add_space(20.0); 87 | 88 | let visuals = &ctx.style().visuals; 89 | 90 | // Error icon and title 91 | ui.horizontal(|ui| { 92 | ui.add_space(5.0); 93 | ui.label(egui::RichText::new("❗").size(30.0)); 94 | ui.add_space(5.0); 95 | ui.label( 96 | egui::RichText::new(&self.title) 97 | .size(16.0) 98 | .strong() 99 | .color(visuals.error_fg_color), 100 | ); 101 | }); 102 | ui.add_space(10.0); 103 | 104 | // Error details in a scrollable frame 105 | egui::ScrollArea::vertical().show(ui, |ui| { 106 | egui::Frame::default().inner_margin(15.0).show(ui, |ui| { 107 | ui.label( 108 | egui::RichText::new(&self.error_message) 109 | .size(12.0) 110 | .family(egui::FontFamily::Monospace), 111 | ); 112 | 113 | ui.add_space(10.0); 114 | 115 | // Additional info if provided 116 | if let Some(info) = &self.additional_info { 117 | ui.horizontal(|ui| { 118 | ui.label(egui::RichText::new(info)); 119 | }); 120 | ui.add_space(10.0); 121 | } 122 | }); 123 | }); 124 | 125 | // OK button to close - centered and prominent 126 | ui.vertical_centered(|ui| { 127 | let button = egui::Button::new(egui::RichText::new("OK").size(14.0).strong()); 128 | if ui.add(button).clicked() { 129 | ctx.send_viewport_cmd(egui::ViewportCommand::Close); 130 | } 131 | }); 132 | }); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /tests/ui_goto_entry_with_filter_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use kiorg::models::dir_entry::DirEntry; 6 | use tempfile::tempdir; 7 | use ui_test_helpers::{create_harness, create_test_files, shift_modifiers}; 8 | 9 | #[test] 10 | fn test_goto_first_entry_with_filter() { 11 | // Create a temporary directory for testing 12 | let temp_dir = tempdir().unwrap(); 13 | create_test_files(&[ 14 | temp_dir.path().join("apple.txt"), 15 | temp_dir.path().join("banana.txt"), 16 | temp_dir.path().join("cherry.txt"), 17 | temp_dir.path().join("date.txt"), 18 | temp_dir.path().join("elderberry.txt"), 19 | ]); 20 | 21 | let mut harness = create_harness(&temp_dir); 22 | 23 | // Activate search 24 | harness.key_press(Key::Slash); 25 | harness.step(); 26 | 27 | // Input search query "berry" (should only match "elderberry.txt") 28 | harness 29 | .input_mut() 30 | .events 31 | .push(egui::Event::Text("berry".to_string())); 32 | harness.step(); 33 | harness.key_press(Key::Enter); 34 | harness.step(); 35 | 36 | // Verify search is active and filtering works 37 | assert_eq!( 38 | harness.state().search_bar.query.as_deref(), 39 | Some("berry"), 40 | "Search query should be 'berry'" 41 | ); 42 | 43 | // Move selection to the middle of the list 44 | harness.key_press(Key::J); 45 | harness.step(); 46 | harness.key_press(Key::J); 47 | harness.step(); 48 | 49 | // Press 'gg' to go to first entry 50 | harness.key_press(Key::G); 51 | harness.step(); 52 | harness.key_press(Key::G); 53 | harness.step(); 54 | 55 | // Verify selection is at the first filtered entry 56 | let query = harness.state().search_bar.query.clone(); 57 | let tab = harness.state_mut().tab_manager.current_tab_mut(); 58 | tab.update_filtered_cache(&query, true, false); 59 | let filtered_entries: Vec<&DirEntry> = tab 60 | .get_cached_filtered_entries() 61 | .iter() 62 | .map(|(entry, _)| entry) 63 | .collect(); 64 | 65 | // Get the selected entry 66 | let selected_entry = &tab.entries[tab.selected_index]; 67 | 68 | // Verify the selected entry is in the filtered list 69 | assert!( 70 | filtered_entries 71 | .iter() 72 | .any(|&entry| entry.path == selected_entry.path), 73 | "Selected entry should be in the filtered list" 74 | ); 75 | 76 | // Verify the selected entry is the first one in the filtered list 77 | assert_eq!( 78 | selected_entry.path, filtered_entries[0].path, 79 | "Selected entry should be the first entry in the filtered list" 80 | ); 81 | } 82 | 83 | #[test] 84 | fn test_goto_last_entry_with_filter() { 85 | // Create a temporary directory for testing 86 | let temp_dir = tempdir().unwrap(); 87 | create_test_files(&[ 88 | temp_dir.path().join("apple.txt"), 89 | temp_dir.path().join("banana.txt"), 90 | temp_dir.path().join("cherry.txt"), 91 | temp_dir.path().join("date.txt"), 92 | temp_dir.path().join("elderberry.txt"), 93 | ]); 94 | 95 | let mut harness = create_harness(&temp_dir); 96 | 97 | // Activate search 98 | harness.key_press(Key::Slash); 99 | harness.step(); 100 | 101 | // Input search query "a" (should match "apple.txt", "banana.txt", "date.txt") 102 | harness 103 | .input_mut() 104 | .events 105 | .push(egui::Event::Text("a".to_string())); 106 | harness.step(); 107 | harness.key_press(Key::Enter); 108 | harness.step(); 109 | 110 | // Verify search is active and filtering works 111 | assert_eq!( 112 | harness.state().search_bar.query.as_deref(), 113 | Some("a"), 114 | "Search query should be 'a'" 115 | ); 116 | 117 | // Press Shift+G to go to last entry 118 | harness.key_press_modifiers(shift_modifiers(), Key::G); 119 | harness.step(); 120 | 121 | // Verify selection is at the last filtered entry 122 | let query = harness.state().search_bar.query.clone(); 123 | let tab = harness.state_mut().tab_manager.current_tab_mut(); 124 | tab.update_filtered_cache(&query, true, false); 125 | let filtered_entries: Vec<&DirEntry> = tab 126 | .get_cached_filtered_entries() 127 | .iter() 128 | .map(|(entry, _)| entry) 129 | .collect(); 130 | 131 | // Get the selected entry 132 | let selected_entry = &tab.entries[tab.selected_index]; 133 | 134 | // Verify the selected entry is in the filtered list 135 | assert!( 136 | filtered_entries 137 | .iter() 138 | .any(|&entry| entry.path == selected_entry.path), 139 | "Selected entry should be in the filtered list" 140 | ); 141 | 142 | // Verify the selected entry is the last one in the filtered list 143 | assert_eq!( 144 | selected_entry.path, 145 | filtered_entries[filtered_entries.len() - 1].path, 146 | "Selected entry should be the last entry in the filtered list" 147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /tests/ui_sort_navigation_bug_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use kiorg::models::tab::SortColumn; 6 | use tempfile::tempdir; 7 | use ui_test_helpers::{create_harness, create_test_files}; 8 | 9 | /// Test to demonstrate the bug where `toggle_sort` doesn't update the `path_to_index` map 10 | /// causing navigation with J/K shortcuts to select the wrong entries 11 | #[test] 12 | fn test_sort_navigation_bug() { 13 | // Create a temporary directory for testing 14 | let temp_dir = tempdir().unwrap(); 15 | 16 | // Create test files with names that will have a different order when sorted 17 | // The initial order will be alphabetical: a.txt, b.txt, c.txt 18 | let test_files = create_test_files(&[ 19 | temp_dir.path().join("a.txt"), 20 | temp_dir.path().join("b.txt"), 21 | temp_dir.path().join("c.txt"), 22 | ]); 23 | 24 | // Start the harness 25 | let mut harness = create_harness(&temp_dir); 26 | 27 | // Verify initial state - entries should be sorted alphabetically 28 | { 29 | let tab = harness.state().tab_manager.current_tab_ref(); 30 | assert_eq!(tab.entries.len(), 3, "Should have 3 entries"); 31 | assert_eq!(tab.entries[0].name, "a.txt", "First entry should be a.txt"); 32 | assert_eq!(tab.entries[1].name, "b.txt", "Second entry should be b.txt"); 33 | assert_eq!(tab.entries[2].name, "c.txt", "Third entry should be c.txt"); 34 | 35 | // Verify path_to_index mapping is correct initially 36 | assert_eq!( 37 | tab.get_index_by_path(&test_files[0]), 38 | Some(0), 39 | "a.txt should be at index 0" 40 | ); 41 | assert_eq!( 42 | tab.get_index_by_path(&test_files[1]), 43 | Some(1), 44 | "b.txt should be at index 1" 45 | ); 46 | assert_eq!( 47 | tab.get_index_by_path(&test_files[2]), 48 | Some(2), 49 | "c.txt should be at index 2" 50 | ); 51 | } 52 | 53 | // Select the first entry (a.txt) 54 | harness 55 | .state_mut() 56 | .tab_manager 57 | .current_tab_mut() 58 | .update_selection(0); 59 | harness.step(); 60 | 61 | // Toggle sort to reverse the order (Name/Descending) 62 | { 63 | let tab_manager = &mut harness.state_mut().tab_manager; 64 | tab_manager.toggle_sort(SortColumn::Name); // First toggle sets to None 65 | tab_manager.toggle_sort(SortColumn::Name); // First toggle sets to Name/Descending 66 | harness.step(); 67 | } 68 | 69 | // Verify entries are now sorted in reverse alphabetical order 70 | { 71 | let tab = harness.state().tab_manager.current_tab_ref(); 72 | assert_eq!(tab.entries.len(), 3, "Should have 3 entries"); 73 | assert_eq!(tab.entries[0].name, "c.txt", "First entry should be c.txt"); 74 | assert_eq!(tab.entries[1].name, "b.txt", "Second entry should be b.txt"); 75 | assert_eq!(tab.entries[2].name, "a.txt", "Third entry should be a.txt"); 76 | } 77 | 78 | // Now let's demonstrate how this affects navigation 79 | // Press J to move down (should select b.txt which is now at index 1) 80 | harness.key_press(Key::J); 81 | harness.step(); 82 | 83 | // Check what entry is selected 84 | { 85 | let tab = harness.state().tab_manager.current_tab_ref(); 86 | let selected_index = tab.selected_index; 87 | let selected_name = &tab.entries[selected_index].name; 88 | 89 | // This assertion might fail due to the bug 90 | assert_eq!( 91 | selected_name, "b.txt", 92 | "After pressing J, b.txt should be selected, but got {selected_name} instead" 93 | ); 94 | } 95 | 96 | // Press J again to move down (should select a.txt which is now at index 2) 97 | harness.key_press(Key::J); 98 | harness.step(); 99 | 100 | // Check what entry is selected 101 | { 102 | let tab = harness.state().tab_manager.current_tab_ref(); 103 | let selected_index = tab.selected_index; 104 | let selected_name = &tab.entries[selected_index].name; 105 | 106 | // This assertion might fail due to the bug 107 | assert_eq!( 108 | selected_name, "a.txt", 109 | "After pressing J twice, a.txt should be selected, but got {selected_name} instead" 110 | ); 111 | } 112 | 113 | // Press K twice to move back up (should select c.txt which is at index 1) 114 | harness.key_press(Key::K); 115 | harness.step(); 116 | harness.key_press(Key::K); 117 | harness.step(); 118 | 119 | // Check what entry is selected 120 | { 121 | let tab = harness.state().tab_manager.current_tab_ref(); 122 | let selected_index = tab.selected_index; 123 | let selected_name = &tab.entries[selected_index].name; 124 | 125 | // This assertion might fail due to the bug 126 | assert_eq!( 127 | selected_name, "c.txt", 128 | "After pressing K twice, c.txt should be selected, but got {selected_name} instead" 129 | ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/popup/preview/image.rs: -------------------------------------------------------------------------------- 1 | //! Image preview module for popup display 2 | 3 | use crate::config::colors::AppColors; 4 | use crate::models::preview_content::ImageMeta; 5 | use egui::{Image, Rect}; 6 | 7 | /// Render image content optimized for popup view 8 | /// 9 | /// This version focuses on displaying the image at a large size without metadata tables 10 | pub fn render_popup( 11 | ui: &mut egui::Ui, 12 | image_meta: &ImageMeta, 13 | _colors: &AppColors, 14 | available_width: f32, 15 | available_height: f32, 16 | ) { 17 | // Use a layout that maximizes image space and supports pan/zoom 18 | ui.vertical_centered(|ui| { 19 | // Calculate available space 20 | let default_init_height = available_height * 0.90; 21 | let default_init_width = available_width * 0.90; 22 | 23 | let image = Image::new(image_meta.image_source.clone()); 24 | let (raw_img_w, raw_img_h) = if let Some(size) = image.size() { 25 | (size[0], size[1]) 26 | } else { 27 | // fallback: use default init size 28 | (default_init_width, default_init_height) 29 | }; 30 | 31 | // Unique id for storing pan/zoom state per image 32 | let id = ui.id().with("image_pan_zoom"); 33 | let mut pan = ui.ctx().data(|d| { 34 | d.get_temp::(id.with("pan")) 35 | .unwrap_or(egui::Vec2::ZERO) 36 | }); 37 | 38 | let init_zoom = || -> f32 { 39 | let scale_x = default_init_width / raw_img_w; 40 | let scale_y = default_init_height / raw_img_h; 41 | scale_x.min(scale_y).min(1.0) 42 | }; 43 | let mut zoom = ui 44 | .ctx() 45 | .data(|d| d.get_temp::(id.with("zoom")).unwrap_or_else(init_zoom)); 46 | let mut reset_view = false; 47 | 48 | egui::ScrollArea::both() 49 | .id_salt("image_scroll_area") 50 | .wheel_scroll_multiplier(egui::Vec2 { x: zoom, y: zoom }) 51 | .show(ui, |ui| { 52 | // The viewport is available_width x available_height 53 | let viewport_size = egui::vec2(available_width, available_height); 54 | let response = 55 | ui.allocate_response(viewport_size, egui::Sense::DRAG | egui::Sense::CLICK); 56 | // Double click to reset zoom and pan 57 | if response.double_clicked() { 58 | reset_view = true; 59 | return; 60 | } 61 | // detect pan through click and drag 62 | if response.dragged() { 63 | // drag_delta is absolute value relative to view port without zoom applied 64 | pan += response.drag_delta() * zoom; 65 | } 66 | 67 | // detect pan and zoom through touch pad 68 | ui.input(|i| { 69 | // Pinch zoom: zoom_delta is a relative multiplier, not an offset 70 | let zoom_delta = i.zoom_delta(); 71 | zoom *= zoom_delta; 72 | // scroll value is absolute vlaue relative to view port without zoom applied 73 | let scroll = i.smooth_scroll_delta; 74 | if scroll.x.abs() > 0.0 { 75 | pan.x += scroll.x * zoom; 76 | } 77 | if scroll.y.abs() > 0.0 { 78 | pan.y += scroll.y * zoom; 79 | } 80 | }); 81 | 82 | // Zoomed image can be larger than the viewport 83 | let scaled_img_size = egui::vec2(raw_img_w, raw_img_h) * zoom; 84 | if scaled_img_size.x <= viewport_size.x { 85 | // disable panning when image is not zoomed in 86 | pan.x = 0.0; 87 | } else { 88 | // Clamp pan so image always shows up in the view area 89 | let half_width = scaled_img_size.x / 2.0; 90 | pan.x = pan.x.clamp(-half_width, half_width); 91 | } 92 | if scaled_img_size.y <= viewport_size.y { 93 | pan.y = 0.0; 94 | } else { 95 | let half_height = scaled_img_size.y / 2.0; 96 | pan.y = pan.y.clamp(-half_height, half_height); 97 | } 98 | 99 | // Store updated state 100 | ui.ctx().data_mut(|d| d.insert_temp(id.with("pan"), pan)); 101 | ui.ctx().data_mut(|d| d.insert_temp(id.with("zoom"), zoom)); 102 | 103 | // use from_center_size to always center image when pan is 0 104 | let paint_rect = 105 | Rect::from_center_size(response.rect.center() + pan, scaled_img_size); 106 | image.paint_at(ui, paint_rect); 107 | }); 108 | 109 | if reset_view { 110 | zoom = init_zoom(); 111 | pan = egui::Vec2::ZERO; 112 | ui.ctx().data_mut(|d| d.insert_temp(id.with("pan"), pan)); 113 | ui.ctx().data_mut(|d| d.insert_temp(id.with("zoom"), zoom)); 114 | } 115 | }); 116 | } 117 | -------------------------------------------------------------------------------- /tests/ui_file_selection_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use kiorg::ui::popup::PopupType; 6 | use tempfile::tempdir; 7 | use ui_test_helpers::{create_harness, create_test_files, ctrl_modifiers, wait_for_condition}; 8 | 9 | /// Integration test that uses Ctrl+A to select all current files and then deletes them 10 | #[test] 11 | fn test_ctrl_a_select_all_and_delete() { 12 | // Create a temporary directory with test files 13 | let temp_dir = tempdir().unwrap(); 14 | let test_files = create_test_files(&[ 15 | temp_dir.path().join("file1.txt"), 16 | temp_dir.path().join("file2.txt"), 17 | temp_dir.path().join("file3.txt"), 18 | temp_dir.path().join("directory1"), 19 | temp_dir.path().join("file4.txt"), 20 | ]); 21 | 22 | let mut harness = create_harness(&temp_dir); 23 | 24 | // Initially, no files should be selected 25 | { 26 | let tab = harness.state().tab_manager.current_tab_ref(); 27 | assert!( 28 | tab.marked_entries.is_empty(), 29 | "No entries should be selected initially" 30 | ); 31 | } 32 | 33 | // Press Ctrl+A to select all entries 34 | harness.key_press_modifiers(ctrl_modifiers(), Key::A); 35 | harness.step(); 36 | 37 | // Verify all entries are now selected 38 | { 39 | let tab = harness.state().tab_manager.current_tab_ref(); 40 | assert_eq!( 41 | tab.marked_entries.len(), 42 | 5, 43 | "All 5 entries should be selected after Ctrl+A" 44 | ); 45 | 46 | // Check that all test files are marked 47 | for test_file in &test_files { 48 | assert!( 49 | tab.marked_entries.contains(test_file), 50 | "File {test_file:?} should be selected" 51 | ); 52 | } 53 | } 54 | 55 | // Press 'd' to trigger delete operation 56 | harness.key_press(Key::D); 57 | harness.step(); 58 | 59 | // Verify the delete popup is shown with all entries 60 | { 61 | let app = harness.state(); 62 | assert!( 63 | matches!(app.show_popup, Some(PopupType::Delete(_, _))), 64 | "Delete popup should be shown" 65 | ); 66 | if let Some(PopupType::Delete(_, entries)) = &app.show_popup { 67 | assert_eq!( 68 | entries.len(), 69 | 5, 70 | "All 5 entries should be marked for deletion" 71 | ); 72 | } 73 | } 74 | 75 | // Confirm the first deletion prompt 76 | harness.key_press(Key::Enter); 77 | harness.step(); 78 | 79 | // Verify we're in the recursive confirmation state 80 | { 81 | let app = harness.state(); 82 | if let Some(PopupType::Delete(state, _)) = &app.show_popup { 83 | assert_eq!( 84 | *state, 85 | kiorg::ui::popup::delete::DeleteConfirmState::RecursiveConfirm, 86 | "Should be in recursive confirmation state after first Enter" 87 | ); 88 | } else { 89 | panic!("Expected Delete popup to be open"); 90 | } 91 | 92 | // Verify files still exist after first confirmation 93 | for test_file in &test_files { 94 | assert!( 95 | test_file.exists(), 96 | "File {test_file:?} should still exist after first confirmation" 97 | ); 98 | } 99 | } 100 | 101 | // Confirm the second deletion prompt 102 | harness.key_press(Key::Enter); 103 | harness.step(); 104 | 105 | // Give time for deletion to process (deletion happens asynchronously) 106 | wait_for_condition(|| { 107 | harness.step(); 108 | harness.state().show_popup.is_none() 109 | }); 110 | 111 | // Verify the delete popup is closed 112 | { 113 | let app = harness.state(); 114 | assert!( 115 | app.show_popup.is_none(), 116 | "Delete popup should be closed after deletion completes" 117 | ); 118 | } 119 | 120 | // Verify all files have been deleted 121 | for test_file in &test_files { 122 | assert!( 123 | !test_file.exists(), 124 | "File {test_file:?} should be deleted after confirmation" 125 | ); 126 | } 127 | 128 | // Verify no entries are selected after deletion 129 | { 130 | let tab = harness.state().tab_manager.current_tab_ref(); 131 | assert!( 132 | tab.marked_entries.is_empty(), 133 | "No entries should be selected after deletion" 134 | ); 135 | } 136 | 137 | // Verify the directory is now empty (except for potential hidden files) 138 | let remaining_entries: Vec<_> = std::fs::read_dir(temp_dir.path()) 139 | .unwrap() 140 | .filter_map(|entry| { 141 | let entry = entry.ok()?; 142 | let name = entry.file_name().to_string_lossy().to_string(); 143 | // Filter out hidden files that might be created by the system 144 | if name.starts_with('.') { 145 | None 146 | } else { 147 | Some(name) 148 | } 149 | }) 150 | .collect(); 151 | 152 | assert!( 153 | remaining_entries.is_empty(), 154 | "Directory should be empty after deleting all files, but found: {remaining_entries:?}" 155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/preview/tar.rs: -------------------------------------------------------------------------------- 1 | //! Tar archive preview module 2 | 3 | use crate::config::colors::AppColors; 4 | use crate::models::preview_content::TarEntry; 5 | use crate::ui::preview::{prefix_dir_name, prefix_file_name}; 6 | use egui::RichText; 7 | use std::fs::File; 8 | use std::io::BufReader; 9 | use tar::Archive; 10 | 11 | /// Render tar archive content 12 | pub fn render(ui: &mut egui::Ui, entries: &[TarEntry], colors: &AppColors) { 13 | // Display tar file contents 14 | ui.label( 15 | RichText::new("Tar Archive Contents:") 16 | .color(colors.fg) 17 | .strong(), 18 | ); 19 | ui.add_space(5.0); 20 | 21 | // Constants for the list 22 | // TODO: calculate the correct row height 23 | const ROW_HEIGHT: f32 = 10.0; 24 | 25 | // Get the total number of entries 26 | let total_rows = entries.len(); 27 | 28 | // Use show_rows for better performance 29 | egui::ScrollArea::vertical() 30 | .id_salt("tar_entries_scroll") 31 | .auto_shrink([false; 2]) 32 | .show_rows(ui, ROW_HEIGHT, total_rows, |ui, row_range| { 33 | // Set width for the content area 34 | let available_width = ui.available_width(); 35 | ui.set_min_width(available_width); 36 | 37 | // Display entries in the visible range 38 | for row_index in row_range { 39 | let entry = &entries[row_index]; 40 | ui.horizontal(|ui| { 41 | // Display permissions 42 | ui.label( 43 | RichText::new(&entry.permissions) 44 | .color(colors.fg_light) 45 | .family(egui::FontFamily::Monospace), 46 | ); 47 | ui.add_space(2.0); 48 | 49 | // Format entry name with prefix 50 | let name_text = if entry.is_dir { 51 | prefix_dir_name(&entry.name) 52 | } else { 53 | prefix_file_name(&entry.name) 54 | }; 55 | ui.label(RichText::new(&name_text).color(colors.fg)); 56 | 57 | // Push size to the right 58 | ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { 59 | if !entry.is_dir { 60 | ui.label( 61 | RichText::new(humansize::format_size( 62 | entry.size, 63 | humansize::BINARY, 64 | )) 65 | .color(colors.fg_light), 66 | ); 67 | } 68 | }); 69 | }); 70 | } 71 | }); 72 | } 73 | 74 | /// Read entries from a tar file and return them as a vector of `TarEntry` 75 | pub fn read_tar_entries(path: &std::path::Path) -> Result, String> { 76 | let file = File::open(path).map_err(|e| format!("Failed to open tar file: {e}"))?; 77 | 78 | // Try to determine if it's compressed by the file extension 79 | let mut archive: Box = Box::new(BufReader::new(file)); 80 | 81 | // Handle compressed tar files 82 | if let Some(ext) = path.extension().and_then(|e| e.to_str()) { 83 | match ext.to_lowercase().as_str() { 84 | "gz" | "tgz" => { 85 | // Re-open file for gzip decompression 86 | let file = 87 | File::open(path).map_err(|e| format!("Failed to reopen tar.gz file: {e}"))?; 88 | let gz = flate2::read::GzDecoder::new(BufReader::new(file)); 89 | archive = Box::new(gz); 90 | } 91 | "bz2" | "tbz" | "tbz2" => { 92 | // Re-open file for bzip2 decompression 93 | let file = 94 | File::open(path).map_err(|e| format!("Failed to reopen tar.bz2 file: {e}"))?; 95 | let bz2 = bzip2::read::BzDecoder::new(BufReader::new(file)); 96 | archive = Box::new(bz2); 97 | } 98 | _ => { 99 | // Uncompressed tar or unknown compression 100 | let file = 101 | File::open(path).map_err(|e| format!("Failed to reopen tar file: {e}"))?; 102 | archive = Box::new(BufReader::new(file)); 103 | } 104 | } 105 | } 106 | 107 | let mut tar = Archive::new(archive); 108 | let mut entries = Vec::new(); 109 | 110 | let tar_entries = tar 111 | .entries() 112 | .map_err(|e| format!("Failed to read tar entries: {e}"))?; 113 | 114 | for entry_result in tar_entries { 115 | let entry = entry_result.map_err(|e| format!("Failed to read tar entry: {e}"))?; 116 | let header = entry.header(); 117 | 118 | // Get the path 119 | let path = entry 120 | .path() 121 | .map_err(|e| format!("Failed to get entry path: {e}"))?; 122 | let name = path.to_string_lossy().to_string(); 123 | 124 | // Check if it's a directory 125 | let is_dir = header.entry_type() == tar::EntryType::Directory; 126 | 127 | // Get size 128 | let size = header.size().unwrap_or(0); 129 | 130 | // Get permissions 131 | let mode = header.mode().unwrap_or(0); 132 | let permissions = format!("{:o}", mode & 0o777); 133 | 134 | entries.push(TarEntry { 135 | name, 136 | size, 137 | is_dir, 138 | permissions, 139 | }); 140 | } 141 | 142 | Ok(entries) 143 | } 144 | -------------------------------------------------------------------------------- /tests/ui_bookmark_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use egui::Key; 5 | use kiorg::ui::popup::PopupType; 6 | use tempfile::tempdir; 7 | use ui_test_helpers::{create_harness, create_test_files, shift_modifiers}; 8 | 9 | #[test] 10 | fn test_bookmark_feature() { 11 | // Create a temporary directory for testing 12 | let temp_dir = tempdir().unwrap(); 13 | 14 | // Create test directories and files 15 | create_test_files(&[ 16 | temp_dir.path().join("dir1"), 17 | temp_dir.path().join("dir2"), 18 | temp_dir.path().join("test1.txt"), 19 | ]); 20 | 21 | let mut harness = create_harness(&temp_dir); 22 | 23 | // Check initial state - no bookmarks 24 | harness.step(); 25 | assert!(harness.state().bookmarks.is_empty()); 26 | assert!(harness.state().show_popup.is_none()); 27 | 28 | // Bookmark the directory with 'b' 29 | harness.key_press(Key::B); 30 | harness.step(); 31 | 32 | // Verify bookmark was added 33 | { 34 | let app = harness.state(); 35 | assert_eq!(app.bookmarks.len(), 1); 36 | assert!(app.bookmarks[0].ends_with("dir1")); 37 | } 38 | 39 | // Open bookmark popup with 'B' (shift+b) 40 | { 41 | harness.key_press_modifiers(shift_modifiers(), Key::B); 42 | harness.step(); 43 | } 44 | 45 | // Verify bookmark popup is shown 46 | if let Some(PopupType::Bookmarks(_)) = harness.state().show_popup { 47 | // Bookmark popup is shown 48 | } else { 49 | panic!("Bookmark popup should be shown"); 50 | } 51 | 52 | // Close bookmark popup with 'q' 53 | { 54 | harness.key_press(Key::Q); 55 | harness.step(); 56 | } 57 | 58 | // Verify bookmark popup is closed 59 | assert!(harness.state().show_popup.is_none()); 60 | 61 | // Select the second directory 62 | { 63 | let tab = harness.state_mut().tab_manager.current_tab_mut(); 64 | tab.selected_index = 1; // Select dir2 65 | } 66 | harness.step(); 67 | 68 | // Bookmark the second directory 69 | harness.key_press(Key::B); 70 | harness.step(); 71 | 72 | // Verify second bookmark was added 73 | { 74 | let app = harness.state(); 75 | assert_eq!(app.bookmarks.len(), 2); 76 | assert!(app.bookmarks[1].ends_with("dir2")); 77 | } 78 | 79 | // Try to bookmark a file (should not work) 80 | { 81 | let tab = harness.state_mut().tab_manager.current_tab_mut(); 82 | tab.selected_index = 2; // Select test1.txt 83 | } 84 | harness.key_press(Key::B); 85 | harness.step(); 86 | 87 | // Verify no new bookmark was added (still 2) 88 | let bookmark_count = harness.state().bookmarks.len(); 89 | assert_eq!( 90 | bookmark_count, 2, 91 | "Expected 2 bookmarks, got {bookmark_count}" 92 | ); 93 | 94 | // Open bookmark popup again 95 | { 96 | harness.key_press_modifiers(shift_modifiers(), Key::B); 97 | harness.step(); 98 | } 99 | 100 | // Delete the first bookmark with 'd' 101 | harness.key_press(Key::D); 102 | harness.step(); 103 | 104 | // Verify bookmark was removed 105 | { 106 | let app = harness.state(); 107 | assert_eq!(app.bookmarks.len(), 1); 108 | assert!(app.bookmarks[0].ends_with("dir2")); // Only dir2 remains 109 | } 110 | 111 | // Close bookmark popup with 'q' 112 | harness.key_press(Key::Q); 113 | harness.step(); 114 | 115 | // Verify bookmark popup is closed 116 | assert!(harness.state().show_popup.is_none()); 117 | } 118 | 119 | #[test] 120 | fn test_bookmark_popup_close_with_q_and_esc() { 121 | // Create a temporary directory for testing 122 | let temp_dir = tempdir().unwrap(); 123 | 124 | // Create test directories 125 | create_test_files(&[temp_dir.path().join("dir1"), temp_dir.path().join("dir2")]); 126 | 127 | let mut harness = create_harness(&temp_dir); 128 | harness.step(); 129 | 130 | // First bookmark a directory so we have something to show in the popup 131 | harness.key_press(Key::B); 132 | harness.step(); 133 | 134 | // Verify bookmark was added 135 | { 136 | let app = harness.state(); 137 | assert_eq!(app.bookmarks.len(), 1); 138 | } 139 | 140 | // Test 1: Open bookmark popup and close with 'q' 141 | { 142 | harness.key_press_modifiers(shift_modifiers(), Key::B); 143 | harness.step(); 144 | } 145 | 146 | // Verify bookmark popup is shown 147 | if let Some(PopupType::Bookmarks(_)) = harness.state().show_popup { 148 | // Bookmark popup is shown 149 | } else { 150 | panic!("Bookmark popup should be shown"); 151 | } 152 | 153 | // Close bookmark popup with 'q' 154 | harness.key_press(Key::Q); 155 | harness.step(); 156 | 157 | // Verify bookmark popup is closed 158 | assert!( 159 | harness.state().show_popup.is_none(), 160 | "Bookmark popup should be closed after pressing 'q'" 161 | ); 162 | 163 | // Test 2: Open bookmark popup and close with 'Esc' 164 | { 165 | harness.key_press_modifiers(shift_modifiers(), Key::B); 166 | harness.step(); 167 | } 168 | 169 | // Verify bookmark popup is shown again 170 | if let Some(PopupType::Bookmarks(_)) = harness.state().show_popup { 171 | // Bookmark popup is shown 172 | } else { 173 | panic!("Bookmark popup should be shown again"); 174 | } 175 | 176 | // Close bookmark popup with 'Esc' 177 | harness.key_press(Key::Escape); 178 | harness.step(); 179 | 180 | // Verify bookmark popup is closed 181 | assert!( 182 | harness.state().show_popup.is_none(), 183 | "Bookmark popup should be closed after pressing 'Esc'" 184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /tests/ui_epub_preview_test.rs: -------------------------------------------------------------------------------- 1 | #[path = "mod/ui_test_helpers.rs"] 2 | mod ui_test_helpers; 3 | 4 | use kiorg::models::preview_content::PreviewContent; 5 | use tempfile::tempdir; 6 | use ui_test_helpers::{create_harness, create_test_epub, wait_for_condition}; 7 | 8 | /// Test for EPUB preview 9 | #[test] 10 | fn test_epub_preview() { 11 | // Create a temporary directory for testing 12 | let temp_dir = tempdir().unwrap(); 13 | 14 | // Create test files including an EPUB file 15 | let epub_path = temp_dir.path().join("test.epub"); 16 | create_test_epub(&epub_path); 17 | 18 | // Create a text file for comparison 19 | let text_path = temp_dir.path().join("test.txt"); 20 | std::fs::write(&text_path, "This is a text file").unwrap(); 21 | 22 | let mut harness = create_harness(&temp_dir); 23 | 24 | harness.key_press(egui::Key::J); 25 | harness.step(); 26 | harness.key_press(egui::Key::K); 27 | harness.step(); 28 | 29 | // Try multiple steps to allow async loading to complete 30 | wait_for_condition(|| match &harness.state().preview_content { 31 | Some(PreviewContent::Epub(_)) => true, 32 | _ => { 33 | harness.step(); 34 | false 35 | } 36 | }); 37 | 38 | match &harness.state().preview_content { 39 | Some(PreviewContent::Epub(epub_meta)) => { 40 | // Verify EPUB metadata 41 | assert!( 42 | !epub_meta.metadata.is_empty(), 43 | "EPUB metadata should not be empty" 44 | ); 45 | 46 | // Check for expected metadata fields 47 | let creator = epub_meta 48 | .metadata 49 | .get("creator") 50 | .or_else(|| epub_meta.metadata.get("dc:creator")); 51 | let language = epub_meta 52 | .metadata 53 | .get("language") 54 | .or_else(|| epub_meta.metadata.get("dc:language")); 55 | 56 | // Title is now stored in the title field, not in metadata 57 | assert!(!epub_meta.title.is_empty(), "Title should not be empty"); 58 | assert!(creator.is_some(), "Creator should be in the EPUB metadata"); 59 | assert!( 60 | language.is_some(), 61 | "Language should be in the EPUB metadata" 62 | ); 63 | 64 | // Check specific values 65 | assert!( 66 | epub_meta.title.contains("Test EPUB Book"), 67 | "Title should contain 'Test EPUB Book'" 68 | ); 69 | 70 | assert!( 71 | epub_meta.page_count > 0, 72 | "EPUB page count should be available and greater than 0" 73 | ); 74 | 75 | if let Some(creator_value) = creator { 76 | assert!( 77 | creator_value.contains("Test Author"), 78 | "Creator should contain 'Test Author'" 79 | ); 80 | } 81 | } 82 | Some(_) => { 83 | panic!("Preview content should be EPUB, but got something else"); 84 | } 85 | None => panic!("Preview content should not be None"), 86 | } 87 | } 88 | 89 | /// Test that EPUB metadata contains page count for right panel display 90 | #[test] 91 | fn test_epub_page_count_metadata_available() { 92 | // Create a temporary directory for testing 93 | let temp_dir = tempdir().unwrap(); 94 | 95 | // Create a test EPUB 96 | let epub_path = temp_dir.path().join("test_metadata.epub"); 97 | create_test_epub(&epub_path); 98 | 99 | // Start the harness 100 | let mut harness = create_harness(&temp_dir); 101 | 102 | // Select the EPUB file 103 | { 104 | let tab = harness.state().tab_manager.current_tab_ref(); 105 | let epub_index = tab 106 | .entries 107 | .iter() 108 | .position(|e| e.name == "test_metadata.epub") 109 | .expect("EPUB file should be in the entries"); 110 | let tab = harness.state_mut().tab_manager.current_tab_mut(); 111 | tab.selected_index = epub_index; 112 | } 113 | 114 | // Step to update the preview 115 | harness.step(); 116 | 117 | // Wait for EPUB processing in a loop, checking for preview content 118 | let mut epub_loaded = false; 119 | wait_for_condition(|| { 120 | harness.step(); 121 | 122 | // Check if EPUB preview content is loaded 123 | if let Some(PreviewContent::Epub(epub_meta)) = &harness.state().preview_content { 124 | // Verify page count is accessible for right panel display 125 | assert!( 126 | epub_meta.page_count > 0, 127 | "EPUB page count should be available and greater than 0" 128 | ); 129 | 130 | // Verify standard EPUB metadata is present 131 | assert!( 132 | !epub_meta.title.is_empty(), 133 | "EPUB should have a non-empty title" 134 | ); 135 | assert!(!epub_meta.metadata.is_empty(), "EPUB should have metadata"); 136 | 137 | // Check for expected metadata fields from the test EPUB 138 | assert!( 139 | epub_meta.metadata.contains_key("creator") 140 | || epub_meta.metadata.contains_key("Creator") 141 | || epub_meta.metadata.contains_key("author") 142 | || epub_meta.metadata.contains_key("Author"), 143 | "EPUB should contain author/creator metadata" 144 | ); 145 | 146 | epub_loaded = true; 147 | true 148 | } else { 149 | false 150 | } 151 | }); 152 | 153 | assert!( 154 | epub_loaded, 155 | "EPUB should have loaded within the timeout period" 156 | ); 157 | } 158 | -------------------------------------------------------------------------------- /crates/kiorg/src/ui/popup/plugin.rs: -------------------------------------------------------------------------------- 1 | use crate::app::Kiorg; 2 | use crate::config::shortcuts::ShortcutAction; 3 | use crate::plugins::manager::{FailedPlugin, LoadedPlugin}; 4 | use egui_extras::{Column, TableBuilder}; 5 | use std::sync::Arc; 6 | 7 | use super::window_utils::show_center_popup_window; 8 | 9 | /// Helper function to display plugins in a table layout 10 | fn display_plugins_table<'a>( 11 | ui: &mut egui::Ui, 12 | plugins: impl Iterator)>, 13 | colors: &crate::config::colors::AppColors, 14 | ) { 15 | TableBuilder::new(ui) 16 | .striped(true) 17 | .resizable(true) 18 | .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) 19 | .column(Column::auto().resizable(true)) 20 | .column(Column::auto().resizable(true)) 21 | .column(Column::auto().resizable(true)) 22 | .column(Column::remainder()) 23 | .header(20.0, |mut header| { 24 | header.col(|ui| { 25 | ui.colored_label(colors.fg_light, "Name"); 26 | }); 27 | header.col(|ui| { 28 | ui.colored_label(colors.fg_light, "Version"); 29 | }); 30 | header.col(|ui| { 31 | ui.colored_label(colors.fg_light, "Load Time"); 32 | }); 33 | header.col(|ui| { 34 | ui.colored_label(colors.fg_light, "Description"); 35 | }); 36 | }) 37 | .body(|mut body| { 38 | for (plugin_name, plugin) in plugins { 39 | body.row(18.0, |mut row| { 40 | let (display_name, description, desc_color) = 41 | if let Some(error_msg) = &plugin.state.lock().unwrap().error { 42 | if error_msg.contains("Incompatible protocol version") { 43 | ( 44 | format!("🚨 {}", plugin_name), 45 | format!("WARN: {}", error_msg), 46 | colors.warn, 47 | ) 48 | } else { 49 | ( 50 | format!("❌ {}", plugin_name), 51 | format!("ERROR: {}", error_msg), 52 | colors.error, 53 | ) 54 | } 55 | } else { 56 | ( 57 | plugin_name.to_string(), 58 | plugin.metadata.description.clone(), 59 | colors.fg, 60 | ) 61 | }; 62 | 63 | // Name 64 | row.col(|ui| { 65 | ui.label(display_name); 66 | }); 67 | 68 | // Version 69 | row.col(|ui| { 70 | ui.label(&plugin.metadata.version); 71 | }); 72 | 73 | // Load Time 74 | row.col(|ui| { 75 | let time_text = format!("{:.2}ms", plugin.load_time.as_secs_f64() * 1000.0); 76 | ui.label(time_text); 77 | }); 78 | 79 | // Description 80 | row.col(|ui| { 81 | ui.colored_label(desc_color, description); 82 | }); 83 | }); 84 | } 85 | }); 86 | } 87 | 88 | /// Helper function to display failed plugins in a grid layout 89 | fn display_failed_plugins_grid<'a>( 90 | ui: &mut egui::Ui, 91 | grid_id: &str, 92 | plugins: impl Iterator, 93 | colors: &crate::config::colors::AppColors, 94 | ) { 95 | egui::Grid::new(grid_id) 96 | .num_columns(2) 97 | .max_col_width(400.0) 98 | .spacing([20.0, 2.0]) 99 | .show(ui, |ui| { 100 | for failed_plugin in plugins { 101 | ui.label(failed_plugin.path.to_string_lossy()); 102 | ui.colored_label(colors.error, &failed_plugin.error); 103 | ui.end_row(); 104 | } 105 | }); 106 | } 107 | 108 | pub fn draw(app: &mut Kiorg, ctx: &egui::Context) { 109 | let mut keep_open = true; 110 | 111 | // Check for shortcut actions based on input 112 | let action = app.get_shortcut_action_from_input(ctx); 113 | if let Some(ShortcutAction::Exit) = action { 114 | app.show_popup = None; 115 | return; 116 | } 117 | 118 | let loaded_plugins_map = app.plugin_manager.list_loaded(); 119 | let failed_plugins_map = app.plugin_manager.list_failed(); 120 | let _ = show_center_popup_window("Plugins", ctx, &mut keep_open, |ui| { 121 | if loaded_plugins_map.is_empty() && failed_plugins_map.is_empty() { 122 | ui.label("No plugins found"); 123 | } else { 124 | egui::ScrollArea::vertical().show(ui, |ui| { 125 | if !loaded_plugins_map.is_empty() { 126 | display_plugins_table(ui, loaded_plugins_map.iter(), &app.colors); 127 | } 128 | 129 | if !failed_plugins_map.is_empty() { 130 | if !loaded_plugins_map.is_empty() { 131 | ui.add_space(10.0); 132 | ui.separator(); 133 | ui.add_space(10.0); 134 | } 135 | ui.colored_label(app.colors.fg_light, "Failed to load plugins"); 136 | display_failed_plugins_grid( 137 | ui, 138 | "failed_plugins_list_grid", 139 | failed_plugins_map.iter(), 140 | &app.colors, 141 | ); 142 | } 143 | }); 144 | } 145 | }); 146 | 147 | if !keep_open { 148 | app.show_popup = None; 149 | } 150 | } 151 | --------------------------------------------------------------------------------