diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36c81a9..ccd8f59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,14 +50,17 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu artifact_name: lla-linux-amd64 + pkg_formats: "deb,rpm,apk,pacman" - os: ubuntu-latest target: aarch64-unknown-linux-gnu artifact_name: lla-linux-arm64 cross_compile: true + pkg_formats: "deb,rpm,apk,pacman" - os: ubuntu-latest target: i686-unknown-linux-gnu artifact_name: lla-linux-i686 cross_compile: true + pkg_formats: "deb,rpm,apk,pacman" # macOS builds - os: macos-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 903ce3f..329d0da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,14 +58,17 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu artifact_name: lla-linux-amd64 + pkg_formats: "deb,rpm,apk,pacman" - os: ubuntu-latest target: aarch64-unknown-linux-gnu artifact_name: lla-linux-arm64 cross_compile: true + pkg_formats: "deb,rpm,apk,pacman" - os: ubuntu-latest target: i686-unknown-linux-gnu artifact_name: lla-linux-i686 cross_compile: true + pkg_formats: "deb,rpm,apk,pacman" # macOS builds - os: macos-latest @@ -197,6 +200,10 @@ jobs: with: path: artifacts + - name: Create themes archive + run: | + find themes -name "*.toml" -type f | zip themes.zip -@ + - name: Generate SHA256 checksums run: | cd artifacts @@ -204,6 +211,7 @@ jobs: sha256sum "$artifact" >> ../SHA256SUMS done cd .. + sha256sum themes.zip >> SHA256SUMS - name: Create Release Notes run: | @@ -245,5 +253,54 @@ jobs: gh release upload "v${{ needs.check_release.outputs.current_version }}" "$asset" --clobber done + # Upload themes archive + gh release upload "v${{ needs.check_release.outputs.current_version }}" themes.zip --clobber + # Upload checksums gh release upload "v${{ needs.check_release.outputs.current_version }}" SHA256SUMS --clobber + + package: + needs: [check_release, build] + if: needs.check_release.outputs.should_release == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + path: artifacts + + - name: Install nFPM + run: | + curl -sfL https://install.goreleaser.com/github.com/goreleaser/nfpm.sh | sh + + - name: Generate packages + run: | + VERSION="${{ needs.check_release.outputs.current_version }}" + + for artifact in artifacts/*; do + if [[ $artifact == *linux* ]]; then + # Generate DEB package + nfpm pkg --packager deb --target "$artifact/lla_${VERSION}_amd64.deb" + + # Generate RPM package + nfpm pkg --packager rpm --target "$artifact/lla-${VERSION}-1.x86_64.rpm" + + # Generate APK package + nfpm pkg --packager apk --target "$artifact/lla-${VERSION}-r0.apk" + + # Generate Pacman package + nfpm pkg --packager archlinux --target "$artifact/lla-${VERSION}-1-x86_64.pkg.tar.zst" + fi + done + + - name: Upload packages + uses: actions/upload-artifact@v3 + with: + name: packages + path: | + **/*.deb + **/*.rpm + **/*.apk + **/*.pkg.tar.zst diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc2448..bf13dc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.9] - 2025-01-04 + +### Added + +- New file management plugins: + + - `file_copier`: Clipboard-based file copying functionality + - `file_mover`: Clipboard-based file moving operations + - `file_remover`: Interactive file and directory removal + - `file_organizer`: File organization with multiple strategies (extension, date, type, size) + +- Enhanced theme system: + + - New `LlaDialoguerTheme` for consistent UI styling + - Additional customization options for symbols and padding + - New theme management commands: `theme pull` and `theme install` + - Improved theme integration across all plugins + +- Improved search capabilities: + - Enhanced fuzzy matching functionality + - Optimized `SearchIndex` for better search operations + +### Documentation + +- A new documentation website is available at [lla.chaqchase.com](https://lla.chaqchase.com) + +### Changed + +- Standardized capitalization of 'lla' across documentation +- Enhanced release workflow with package generation +- Improved plugin documentation and installation instructions +- Integrated `lla_plugin_utils` across plugins for better consistency + +### Fixed + +- Coloring issue for icons in the tree format + ## [0.3.8] - 2024-12-21 ### Added diff --git a/Cargo.lock b/Cargo.lock index 563c295..ff9947b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -587,6 +587,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "file_copier" +version = "0.1.0" +dependencies = [ + "colored", + "dialoguer", + "dirs", + "lazy_static", + "lla_plugin_interface", + "lla_plugin_utils", + "parking_lot", + "serde", + "serde_json", +] + [[package]] name = "file_hash" version = "0.3.1" @@ -618,6 +633,50 @@ dependencies = [ "serde", ] +[[package]] +name = "file_mover" +version = "0.1.0" +dependencies = [ + "colored", + "dialoguer", + "dirs", + "lazy_static", + "lla_plugin_interface", + "lla_plugin_utils", + "parking_lot", + "serde", + "serde_json", +] + +[[package]] +name = "file_organizer" +version = "0.1.0" +dependencies = [ + "chrono", + "colored", + "dirs", + "lazy_static", + "lla_plugin_interface", + "lla_plugin_utils", + "parking_lot", + "serde", + "toml", +] + +[[package]] +name = "file_remover" +version = "0.1.0" +dependencies = [ + "colored", + "dialoguer", + "lazy_static", + "lla_plugin_interface", + "lla_plugin_utils", + "parking_lot", + "serde", + "serde_json", +] + [[package]] name = "file_tagger" version = "0.3.1" @@ -993,7 +1052,7 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "lla" -version = "0.3.8" +version = "0.3.9" dependencies = [ "atty", "chrono", @@ -1006,11 +1065,13 @@ dependencies = [ "dashmap", "dialoguer", "dirs", + "fuzzy-matcher", "glob", "ignore", "indicatif", "libloading", "lla_plugin_interface", + "lla_plugin_utils", "num_cpus", "once_cell", "parking_lot", @@ -1031,7 +1092,7 @@ dependencies = [ [[package]] name = "lla_plugin_interface" -version = "0.3.8" +version = "0.3.9" dependencies = [ "prost", "prost-build", @@ -1040,7 +1101,7 @@ dependencies = [ [[package]] name = "lla_plugin_utils" -version = "0.3.8" +version = "0.3.9" dependencies = [ "bytes", "chrono", diff --git a/Cargo.toml b/Cargo.toml index b930868..e6a068c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,12 +5,12 @@ members = ["lla", "lla_plugin_interface", "lla_plugin_utils", "plugins/*"] [workspace.package] description = "Blazing Fast and highly customizable ls Replacement with Superpowers" authors = ["Achaq "] -version = "0.3.8" +version = "0.3.9" categories = ["utilities", "file-system", "cli", "file-management"] edition = "2021" license = "MIT" keywords = ["ls", "file-system", "cli", "file-management"] -repository = "https://github.com/triyanox/lla" +repository = "https://github.com/chaqchase/lla" [workspace.dependencies] clap = "3.2.25" diff --git a/README.md b/README.md index c846ede..bf9e804 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,79 @@ -# `lla` - Blazing Fast `ls` Replacement with Superpowers - - - AUR package - - - crates.io package - - - Homebrew package - - - nixpkgs unstable package - - - pkgsrc current package - +

+

+ Logo +
lla +

+ +

+ Modern, customizable, feature-rich and extensible `ls` replacement. +
+
+ Documentation + · + Features + · + Installation + · + Display Formats + · + Command Reference +

+

## Overview -`lla` is a high-performance file explorer written in Rust that enhances the traditional `ls` command with modern features, rich formatting options, and a powerful plugin system. - -## Table of Contents - -- [Installation](#installation) -- [Features](#features) - - [Display Formats](#display-formats) - - [Core Features](#core-features) -- [Usage](#usage) - - [Command Reference](#command-reference) - - [Filter System](#filter-system) - - [Plugin System](#plugin-system) -- [Configuration](#configuration) -- [Theming](#theming) -- [Completion](#completion) -- [Development](#development) - - [Plugin Development](#plugin-development) - - [Contributing](#contributing) -- [License](#license) +lla is a modern `ls` replacement that transforms how developers interact with their filesystem. Built with Rust's performance capabilities and designed with user experience in mind, lla combines the familiarity of `ls` with powerful features like specialized views, Git integration, and a robust plugin system with an extensible list of plugins to add more functionality. + +## Features + +- Multiple Views: Default clean view, long format, tree structure, table layout, grid display +- Git Integration: Built-in status visualization and repository insights +- Advanced Organization: Timeline view, storage analysis, recursive exploration +- Smart Search: complex filtering patterns (OR, AND, NOT, XOR), regex support +- Customization: Plugin system, theme manager, custom shortcuts, configurable display +- High Performance: Built with Rust, modern UI, clean listings +- Smart Sorting: Multiple criteria, directory-first option, natural sorting +- Flexible Config: Easy initialization, plugin management, configuration tools +- Rich Plugin Ecosystem: File ops and metadata enhancements, code analysis, git tools, and more ## Installation +### Using Installation Script + +The easiest way to install lla is using our installation script: + ```bash -# Using Cargo -cargo install lla +curl -sSL https://raw.githubusercontent.com/chaqchase/lla/main/install.sh | bash +``` + +This script will automatically: -# On macOS -brew install lla +- Detect your OS and architecture +- Download the appropriate binary +- Verify the checksum +- Install lla to `/usr/local/bin` -# On Arch Linux -paru -S lla +### Using Package Manager -# On NetBSD -pkgin install lla +| Package Manager / Platform | Command | +| -------------------------- | ------------------- | +| Cargo | `cargo install lla` | +| macOS (Homebrew) | `brew install lla` | +| Arch Linux (paru) | `paru -S lla` | +| NetBSD (pkgin) | `pkgin install lla` | +### Manual Installation + +```bash # Manual - Example is for amd64 GNU, replaces the file names if downloading for a different arch. -wget -c https://github.com/triyanox/lla/releases/download/v0.3.6/lla-linux-amd64 -O lla +wget -c https://github.com/triyanox/lla/releases/download//-> -O lla # Example /v0.3.9/lla-linux-amd64 sudo chmod +x lla sudo chown root:root lla sudo mv lla /usr/local/bin/lla ``` +### Post Installation + After installation, initialize your setup: ```bash @@ -70,591 +84,260 @@ lla init lla config ``` -## Features +[![Packaging status](https://repology.org/badge/vertical-allrepos/lla-ls.svg)](https://repology.org/project/lla-ls/versions) -### Display Formats +## Display Formats + +### Core Views #### Default View -Quick and clean directory listing +Clean, distraction-free listing for quick directory scans: ```bash lla ``` -![default](https://github.com/user-attachments/assets/ba5fa273-c2c4-4143-b199-ab5bff1bb608) +default #### Long Format -Detailed file information with metadata +Rich metadata display for detailed file analysis: ```bash lla -l ``` -![long](https://github.com/user-attachments/assets/c736720c-cd00-453e-b5b3-5cdf91fd60d1) +long -#### Tree View +#### Tree Structure -Hierarchical directory visualization +Hierarchical exploration of directory relationships: ```bash -lla -t +lla -t -d 3 # Navigate up to 3 levels deep ``` -![tree](https://github.com/user-attachments/assets/e8593ec5-5566-44ba-bbe6-712ebd1656ec) +tree + +### Enhanced Organization -#### Table View +#### Table Layout -Structured data display +Structured view optimized for data comparison: ```bash lla -T ``` -![table](https://github.com/user-attachments/assets/707a76e3-fdda-4de1-9733-5bc2a05b80e0) +table -#### Grid View +#### Grid Display -Organized layout for better readability +Space-efficient layout for dense directories: ```bash lla -g ``` -![grid](https://github.com/user-attachments/assets/68af78a6-277c-4942-ba46-068c04442fa4) +grid + +### Specialized Views -#### Git-Aware View +#### Git Integration -Repository status and insights +Smart repository insights: ```bash lla -G ``` -![git](https://github.com/user-attachments/assets/127b8b6b-47d8-4fa5-95d6-d0217c253b72) +git -#### Timeline View +#### Timeline Organization -Group files by dates +Chronological file tracking: ```bash lla --timeline ``` -![timeline](https://github.com/user-attachments/assets/a5cd7579-6235-4757-a2b5-1a61735093fd) +timeline -#### Sizemap View +#### Storage Analysis -Visualize file sizes relative to each other +Visual disk usage insights: ```bash -lla -S +lla -S # use --include-dirs to calculate directories sizes ``` -![sizemap](https://github.com/user-attachments/assets/07cdf955-3f8d-4ae7-bbd0-ce97d7472967) +sizemap + +### Advanced Navigation #### Fuzzy Search (Experimental) -Search for files using fuzzy matching +Interactive file discovery: ```bash lla --fuzzy ``` -![fuzzy](https://github.com/user-attachments/assets/98bf579c-ce71-4a4f-b0fc-bdb769d9cfe9) - -#### Recursive Format - -List all files in a directory recursively - -```bash -lla -R # use -d to control the depth -``` - -![recursive](https://github.com/user-attachments/assets/6ba6b968-8fe3-475c-84c5-debdee2f97f5) - -### Core Features - -**Display Options** - -- Long format (`-l`, `--long`): Detailed file information with metadata -- Tree view (`-t`, `--tree`): Hierarchical directory visualization -- Table view (`-T`, `--table`): Structured data display -- Grid view (`-g`, `--grid`): Organized layout for better readability -- Size map (`-S`, `--sizemap`): Visual representation of file sizes -- Timeline view (`--timeline`): Group files by time periods -- Git-aware view (`-G`, `--git`): Repository status and information -- Fuzzy search (`--fuzzy`): Search for files using fuzzy matching (Experimental) -- Recursive format (`-R`, `--recursive`): List all files in a directory recursively -- Icons (`--icons`): Show icons for files and directories -- No colors (`--no-colors`): Disable colors in all listing commands -- No icons (`--no-icons`): Disable icons in all listing commands -- Include directories (`--include-dirs`): Include the size of directories in the metadata, it will recursively calculate the size of all directories, works with all listing commands - -**Organization & Sorting** - -- Sort files (`-s`, `--sort`): - - By name (default) - - By size - - By date (modification time) -- Sort modifiers: - - Reverse order (`-r`, `--sort-reverse`) - - Directories first (`--sort-dirs-first`) - - Case-sensitive (`--sort-case-sensitive`) - - Natural number sorting (`--sort-natural`) -- Recursive listing with depth control (`-d`, `--depth`) -- Performance optimization for large directories: - - Configurable maximum entries for tree view (default: 20,000) - - Configurable maximum entries for recursive listing (default: 20,000) - -**File Filtering** - -- Filter by pattern (`-f`, `--filter`) -- Case-sensitive filtering (`-c`, `--case-sensitive`) -- Support for complex filter patterns: - - - Simple text matching - - Regular expressions - - Glob patterns - - Logical operators (AND, OR, NOT, XOR) - -- Show only specific types: - - `--dirs-only`: Show only directories - - `--files-only`: Show only regular files - - `--symlinks-only`: Show only symbolic links - - `--dotfiles-only`: Show only dot files and directories (those starting with a dot) -- Hide specific types: - - `--no-dirs`: Hide directories - - `--no-files`: Hide regular files - - `--no-symlinks`: Hide symbolic links - - `--no-dotfiles`: Hide dot files and directories (those starting with a dot) - -**Plugin System** - -- Enable/disable plugins (`--enable-plugin`, `--disable-plugin`) -- Custom plugin directory support (`--plugins-dir`) -- Plugin action support (`--plugin-arg`) - -## Usage - -### Command Reference - -**Basic Usage** - -```bash -lla # List current directory (default view) -lla /path/to/dir # List specific directory -lla -l # Long format with detailed information -lla -t # Tree view -lla -T # Table view -lla -g # Grid view -lla -G # Git-aware view -lla -S # Size map view - you can also use the `include_dirs` flag to include the size of directories -lla --timeline # Timeline view -lla --icons # Show file/directory icons -lla --fuzzy # Fuzzy search (Experimental) -lla -R # Recursive format - use -d to control the depth -``` - -**Sorting & Organization** - -```bash -lla -s name # Sort by name (default) -lla -s size # Sort by size -lla -s date # Sort by date -lla -r # Reverse sort order -lla --sort-dirs-first # List directories before files -lla --sort-case-sensitive # Case-sensitive sorting -lla --sort-natural # Natural number sorting (2.txt before 10.txt) -``` - -**Depth Control** - -```bash -lla -d 2 # List directory tree with depth 2 -lla -t -d 3 # Tree view with max depth 3 -``` - -### Filter System - -#### Basic Filtering - -```bash -lla -f "test" # Find files containing "test" -lla -f "test" -c # Case-sensitive search -lla -f ".rs" # Find files with .rs extension - -# Show only specific types -lla --dirs-only # Show only directories -lla --files-only # Show only regular files -lla --symlinks-only # Show only symbolic links -lla --dotfiles-only # Show only dot files and directories - -# Hide specific types -lla --no-dirs # Hide directories -lla --no-files # Hide regular files -lla --no-symlinks # Hide symbolic links -lla --no-dotfiles # Hide dot files and directories - -# Combine filters -lla --dirs-only --dotfiles-only # Show only dot directories -lla --files-only --no-dotfiles # Show only regular files, excluding dot files -``` - -#### Advanced Filters - -**Pattern Filters** - -```bash -lla -f "test,spec" # OR operation -lla -f "+test,api" # AND operation -``` - -**Regular Expression Filters** - -```bash -lla -f "regex:^test.*\.rs$" # Rust files starting with "test" -lla -f "regex:\d{4}" # Files containing 4 digits -``` - -**Glob Pattern Filters** - -```bash -lla -f "glob:*.{rs,toml}" # Match .rs or .toml files -lla -f "glob:test_*" # Files starting with test_ -``` - -#### Composite Filters - -```bash -lla -f "test AND .rs" # AND operation -lla -f "test OR spec" # OR operation -lla -f "NOT test" # NOT operation -lla -f "test XOR spec" # XOR operation -``` - -### Plugin System - -https://github.com/user-attachments/assets/6064b70e-1275-4d60-88ed-3370c0068ebd - -**Installation** - -```bash -# From Git repository -lla install --git https://github.com/user/plugin - -# From local directory -lla install --dir path/to/plugin -``` - -**Management** - -You can use the following commands to manage plugins: - -```bash -lla use # Interactive plugin manager -lla --enable-plugin name # Enable plugin -lla --disable-plugin name # Disable plugin -lla update # Update all plugins -# You also update a single plugin -lla update file_tagger -``` - -**Plugin Actions:** - -Plugin actions are the functions which a given plugin can perform. +fuzzy -```bash -lla plugin --name file_tagger --action add-tag --args README.md "important" -``` +#### Deep Directory Exploration (Recursive) -**Creating Shortcuts** - -Shortcuts allow you to save frequently used plugin commands with simpler aliases: +Comprehensive subdirectory listing: ```bash -# Add a shortcut -lla shortcut add find file_finder search -d "Quick file search" -lla shortcut add hash file_hash calculate -d "Calculate file hashes" -lla shortcut add todos keyword_search find-todos -d "Find TODO comments" - -# List all shortcuts -lla shortcut list - -# Use shortcuts (remaining arguments are passed to the plugin) -lla find pattern # Equivalent to: lla plugin --name file_finder --action search --args "pattern" -lla hash filename # Equivalent to: lla plugin --name file_hash --action calculate --args "filename" -lla todos # Equivalent to: lla plugin --name keyword_search --action find-todos - -# Remove a shortcut -lla shortcut remove find +lla -R +lla -R -d 3 # Set exploration depth ``` -## Configuration - -The configuration file is located at `~/.config/lla/config.toml`. You can modify it directly or use the `lla config` command. - -**Configuration File Format:** - -```toml -# LLA Configuration File -# This file controls the behavior and appearance of the lla command - -# Default sorting method for file listings -# Possible values: -# - "name": Sort alphabetically by filename (default) -# - "size": Sort by file size, largest first -# - "date": Sort by modification time, newest first -default_sort = "name" - -# Default format for displaying files -# Possible values: -# - "default": Quick and clean directory listing -# - "long": Detailed file information with metadata -# - "tree": Hierarchical directory visualization -# - "fuzzy": Interactive fuzzy search -# - "grid": Organized grid layout for better readability -# - "git": Git-aware view with repository status -# - "timeline": Group files by time periods -# - "sizemap": Visual representation of file sizes -# - "table": Structured data display -default_format = "default" - -# Whether to show icons by default -# When true, file and directory icons will be displayed in all views -# Default: false -show_icons = true - -# Whether to include directory sizes in file listings -# When true, directory sizes will be calculated recursively -# This may impact performance for large directories -# Default: false -include_dirs = false - -# The theme to use for coloring -# Place custom themes in ~/.config/lla/themes/ -# Default: "default" -theme = "default" - -# List of enabled plugins -# Each plugin provides additional functionality -# Examples: -# - "git_status": Show Git repository information -# - "file_hash": Calculate and display file hashes -# - "file_tagger": Add and manage file tags -enabled_plugins = [] - -# Directory where plugins are stored -# Default: ~/.config/lla/plugins -plugins_dir = "~/.config/lla/plugins" - -# Maximum depth for recursive directory traversal -# Controls how deep lla will go when showing directory contents -# Set to None for unlimited depth (may impact performance) -# Default: 3 levels deep -default_depth = 3 - -# Sorting configuration -[sort] -# List directories before files -# Default: false -dirs_first = false - -# Enable case-sensitive sorting -# Default: false -case_sensitive = false - -# Use natural sorting for numbers (e.g., 2.txt before 10.txt) -# Default: true -natural = true - -# Filtering configuration -[filter] -# Enable case-sensitive filtering by default -# Default: false -case_sensitive = false - -# Hide dot files and directories by default -# Default: false -no_dotfiles = false - -# Formatter-specific configurations -[formatters.tree] -# Maximum number of entries to display in tree view -# Controls memory usage and performance for large directories -# Set to 0 to show all entries (may impact performance) -# Default: 20000 entries -max_lines = 20000 - -# Lister-specific configurations -[listers.recursive] -# Maximum number of entries to process in recursive listing -# Controls memory usage and performance for deep directory structures -# Set to 0 to process all entries (may impact performance) -# Default: 20000 entries -max_entries = 20000 - -# Fuzzy lister configuration -[listers.fuzzy] -# Patterns to ignore when listing files in fuzzy mode -# Can be: -# - Simple substring match: "node_modules" -# - Glob pattern: "glob:*.min.js" -# - Regular expression: "regex:.*\\.pyc$" -# Default: ["node_modules", "target", ".git", ".idea", ".vscode"] -ignore_patterns = ["node_modules","target",".git",".idea",".vscode"] -``` +recursive -**Configuration Commands:** +The `-R` option can be integrated with other options to create a more specific view. For example, `lla -R -l` +will show a detailed listing of all files and directories in the current directory. -```bash -# Initialize config -lla init # Create default config file - -# View current config -lla config # Display current configuration - -# Modify settings -lla config --set default_sort size -lla config --set default_format long -lla config --set show_icons true -lla config --set sort.dirs_first true -lla config --set sort.case_sensitive true -lla config --set filter.case_sensitive true -lla config --set filter.no_dotfiles true # Hide dot files by default - -# Manage shortcuts -lla shortcut add NAME PLUGIN ACTION [-d DESCRIPTION] # Add shortcut -lla shortcut remove NAME # Remove shortcut -lla shortcut list # List all shortcuts -``` +## Command Reference -CLI Configuration: +### Display Options -```bash -lla config --set default_sort size -lla config --set default_format long -``` +#### Basic Views -## Theming +| Command | Short | Description | Example | +| --------- | ----- | --------------------------------------- | -------- | +| (default) | | List current directory | `lla` | +| `--long` | `-l` | Detailed file information with metadata | `lla -l` | +| `--tree` | `-t` | Hierarchical directory visualization | `lla -t` | +| `--table` | `-T` | Structured data display | `lla -T` | +| `--grid` | `-g` | Organized grid layout | `lla -g` | -LLA includes a powerful theming system that allows you to customize the appearance of your file listings. Themes are defined in TOML files and stored in `~/.config/lla/themes/`. +#### Advanced Views -You can use the interactive theme manager to switch themes using the `lla theme` command: +| Command | Short | Description | Example | +| ------------- | ----- | --------------------------------------- | ------------------------------------- | +| `--sizemap` | `-S` | Visual representation of file sizes | `lla -S`
`lla -S --include-dirs` | +| `--timeline` | | Group files by time periods | `lla --timeline` | +| `--git` | `-G` | Show git status and information | `lla -G` | +| `--fuzzy` | `-F` | Interactive fuzzy finder (Experimental) | `lla --fuzzy` | +| `--recursive` | `-R` | Recursive listing format | `lla -R`
`lla -R -d 3` | -```bash -lla theme -``` +#### Display Modifiers -![themes](https://github.com/user-attachments/assets/23ef3b9c-626b-45d1-afcf-22d249c8e499) - -**Theme Structure:** - -```toml -# Theme metadata -name = "my_theme" -author = "Your Name" -description = "A description of your theme" - -# Core colors -[colors] -file = "#FFFFFF" # Regular files -directory = "#89AFFF" # Directories -symlink = "#89DBFF" # Symbolic links -executable = "#5DE4B3" # Executable files - -# Special files -[special_files] -folders."node_modules" = "#666666" -dotfiles.".env" = "#FFFFC2" -exact_match."README.md" = "#FFFFC2" -patterns."*.min.*" = "#282E30" - -# Extension-based colors -[extensions.groups] -rust = ["rs", "toml"] -web = ["html", "css", "js"] - -[extensions.colors] -rust = "#FF5733" -web = "#61AFEF" -``` +| Command | Description | Example | +| ------------ | ------------------------------------ | ---------------- | +| `--icons` | Show icons for files and directories | `lla --icons` | +| `--no-icons` | Hide icons for files and directories | `lla --no-icons` | +| `--no-color` | Disable all colors in the output | `lla --no-color` | -**Built-in Themes:** +### Sort & Filter Options -- **default**: Traditional terminal colors optimized for visibility -- **dark**: Modern dark theme with enhanced visibility -- **light**: Clean theme optimized for light terminals -- **ayu_dark**, **ayu_light**, **ayu_mirage**: Inspired by the Ayu color scheme -- **catppuccin_mocha**: Soothing pastel theme with warm colors -- **dracula**: Classic Dracula scheme with vibrant colors -- **gruvbox_dark**: Retro groove theme with earthy tones -- **material_ocean**: Deep blue Material Design theme -- **nord**: Arctic, north-bluish color palette -- **one_dark**: Atom-inspired balanced dark theme -- **poimandres**: Deep space aesthetic theme -- **tokyo_night**: Vibrant dark theme inspired by Tokyo nights -- **vesper**: Minimalist dark theme with warm accents +#### Sorting -**Using Themes:** +| Command | Short | Description | Example | +| ----------------------- | ----- | -------------------------------------------- | --------------------------------------------------- | +| `--sort` | `-s` | Sort files by criteria | `lla -s name`
`lla -s size`
`lla -s date` | +| `--sort-reverse` | `-r` | Reverse the sort order | `lla -s size -r` | +| `--sort-dirs-first` | | List directories before files | `lla --sort-dirs-first` | +| `--sort-case-sensitive` | | Enable case-sensitive sorting | `lla --sort-case-sensitive` | +| `--sort-natural` | | Natural number sorting (2.txt before 10.txt) | `lla --sort-natural` | -Copy the theme you want to use to your `~/.config/lla/themes/` directory. - -and then set the theme in your config or use the command line: - -```bash -# Set theme in config -lla config --set theme dark - -# Disable colors -lla config --set theme none -``` - -also you can disable colors all together by using the `--no-colors` flag: - -```bash -lla --no-colors # works with all listing commands -``` - -For more detailed information about theming, see the [themes documentation](themes/README.md). - -## Completion - -LLA supports shell completion for bash, zsh, fish, and elvish. - -```bash -lla completion bash # for bash -lla completion zsh # for zsh -lla completion fish # for fish -lla completion elvish # for elvish -``` - -You can also generate completions in a specific directory by running the following command: - -```bash -lla completion --output /path/to/completion -``` - -All completions are included [here](https://github.com/triyanox/lla/tree/main/completions). - -## Development - -### Plugin Development - -Develop custom plugins using the `Plugin` trait from [lla_plugin_interface](https://github.com/triyanox/lla/tree/main/lla_plugin_interface). More details in the [plugin development guide](https://github.com/triyanox/lla/tree/main/lla_plugin_interface). - -### Contributing - -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/new-feature`) -3. Commit your changes (`git commit -m 'Add some new-feature'`) -4. Push to the branch (`git push origin feature/new-feature`) -5. Open a Pull Request - -If you want to add a new theme, please add it to the `themes/` directory. +#### Basic Filtering -If you want to add a new plugin, please add it to the `plugins/` directory or you can use your own repo to host it. +| Command | Short | Description | Example | +| ------------------ | ----- | ------------------------------- | ----------------------------------- | +| `--filter` | `-f` | Filter files by pattern | `lla -f "test"`
`lla -f ".rs"` | +| `--case-sensitive` | `-c` | Enable case-sensitive filtering | `lla -f "test" -c` | +| `--depth` | `-d` | Set the depth for tree listing | `lla -t -d 3`
`lla -d 2` | + +#### Advanced Filtering Patterns + +| Filter Type | Example | Description | +| ------------------ | ----------------------------- | ---------------------------------------------- | +| OR Operation | `lla -f "test,spec"` | Match files containing either "test" or "spec" | +| AND Operation | `lla -f "+test,api"` | Match files containing both "test" and "api" | +| Regular Expression | `lla -f "regex:^test.*\.rs$"` | Rust files starting with "test" | +| Glob Pattern | `lla -f "glob:*.{rs,toml}"` | Match .rs or .toml files | +| Composite AND | `lla -f "test AND .rs"` | Logical AND operation | +| Composite OR | `lla -f "test OR spec"` | Logical OR operation | +| Composite NOT | `lla -f "NOT test"` | Logical NOT operation | +| Composite XOR | `lla -f "test XOR spec"` | Logical XOR operation | + +### View Filters + +#### Show Only Filters + +| Command | Description | Example | +| ----------------- | ----------------------------------- | --------------------- | +| `--dirs-only` | Show only directories | `lla --dirs-only` | +| `--files-only` | Show only regular files | `lla --files-only` | +| `--symlinks-only` | Show only symbolic links | `lla --symlinks-only` | +| `--dotfiles-only` | Show only dot files and directories | `lla --dotfiles-only` | + +#### Hide Filters + +| Command | Description | Example | +| --------------- | ------------------------------ | ------------------- | +| `--no-dirs` | Hide directories | `lla --no-dirs` | +| `--no-files` | Hide regular files | `lla --no-files` | +| `--no-symlinks` | Hide symbolic links | `lla --no-symlinks` | +| `--no-dotfiles` | Hide dot files and directories | `lla --no-dotfiles` | + +#### Combined Filters + +| Description | Example | +| -------------------------------------------- | --------------------------------- | +| Show only dot directories | `lla --dirs-only --dotfiles-only` | +| Show only regular files, excluding dot files | `lla --files-only --no-dotfiles` | + +### Plugin Management + +#### Installation + +| Command | Description | Example | +| --------------- | ---------------------------- | -------------------------------------------------- | +| `install --git` | Install from Git repository | `lla install --git https://github.com/user/plugin` | +| `install --dir` | Install from local directory | `lla install --dir path/to/plugin` | + +#### Plugin Controls + +| Command | Description | Example | +| ------------------ | -------------------------- | ----------------------------------------------------------------------------- | +| `use` | Interactive plugin manager | `lla use` | +| `--enable-plugin` | Enable specific plugins | `lla --enable-plugin name` | +| `--disable-plugin` | Disable specific plugins | `lla --disable-plugin name` | +| `update` | Update plugins | `lla update`
`lla update file_tagger` | +| `plugin` | Run plugin actions | `lla plugin --name file_tagger --action add-tag --args README.md "important"` | + +#### Shortcut Management + +| Command | Description | Example | +| ----------------- | ------------------ | ----------------------------------------------------------------- | +| `shortcut add` | Add a new shortcut | `lla shortcut add find file_finder search -d "Quick file search"` | +| `shortcut remove` | Remove a shortcut | `lla shortcut remove find` | +| `shortcut list` | List all shortcuts | `lla shortcut list` | + +### Configuration & Setup + +| Command | Description | Example | +| ------------ | --------------------------------- | --------------------- | +| `init` | Initialize the configuration file | `lla init` | +| `config` | View or modify configuration | `lla config` | +| `theme` | Interactive theme manager | `lla theme` | +| `completion` | Generate shell completion scripts | `lla completion bash` | +| `clean` | Clean up invalid plugins | `lla clean` | + +### General Options + +| Command | Short | Description | +| ----------- | ----- | ------------------------- | +| `--help` | `-h` | Print help information | +| `--version` | `-V` | Print version information | + +> **Note** +> For detailed usage and examples of each command, visit the [lla documentation](https://lla.chaqchase.com). ## License diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e265575 --- /dev/null +++ b/install.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_step() { + echo -e "${BLUE}==>${NC} $1" +} + +print_success() { + echo -e "${GREEN}==>${NC} $1" +} + +print_error() { + echo -e "${RED}==>${NC} $1" +} + +detect_platform() { + OS="$(uname -s)" + ARCH="$(uname -m)" + + case "$OS" in + Linux) OS="linux" ;; + Darwin) OS="macos" ;; + *) + print_error "Unsupported operating system: $OS" + exit 1 + ;; + esac + + case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64" ;; + arm64) ARCH="arm64" ;; + i386) ARCH="i686" ;; + i686) ARCH="i686" ;; + *) + print_error "Unsupported architecture: $ARCH" + exit 1 + ;; + esac + + PLATFORM="lla-${OS}-${ARCH}" +} + +get_latest_version() { + LATEST_RELEASE_URL="https://api.github.com/repos/chaqchase/lla/releases/latest" + VERSION=$(curl -s $LATEST_RELEASE_URL | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + if [ -z "$VERSION" ]; then + print_error "Failed to fetch latest version" + exit 1 + fi +} + +download_binary() { + print_step "Downloading lla ${VERSION} for ${OS}-${ARCH}..." + + DOWNLOAD_URL="https://github.com/chaqchase/lla/releases/download/${VERSION}/${PLATFORM}" + TMP_DIR=$(mktemp -d) + curl -L "$DOWNLOAD_URL" -o "${TMP_DIR}/lla" + + if [ $? -ne 0 ]; then + print_error "Failed to download binary" + rm -rf "$TMP_DIR" + exit 1 + fi +} + +verify_checksum() { + print_step "Verifying checksum..." + + CHECKSUM_URL="https://github.com/chaqchase/lla/releases/download/${VERSION}/SHA256SUMS" + curl -L "$CHECKSUM_URL" -o "${TMP_DIR}/SHA256SUMS" + + cd "$TMP_DIR" + if ! sha256sum -c --ignore-missing SHA256SUMS; then + print_error "Checksum verification failed" + cd - > /dev/null + rm -rf "$TMP_DIR" + exit 1 + fi + cd - > /dev/null +} + +install_binary() { + print_step "Installing lla to /usr/local/bin..." + + sudo mkdir -p /usr/local/bin + sudo chmod +x "${TMP_DIR}/lla" + sudo mv "${TMP_DIR}/lla" /usr/local/bin/ + rm -rf "$TMP_DIR" + print_success "lla ${VERSION} has been installed successfully!" + print_success "Run 'lla init' to create your configuration file" +} + +main() { + print_step "Installing lla..." + if ! command -v curl >/dev/null 2>&1; then + print_error "curl is required but not installed" + exit 1 + fi + + detect_platform + get_latest_version + download_binary + verify_checksum + install_binary +} + +main \ No newline at end of file diff --git a/lla/Cargo.toml b/lla/Cargo.toml index 129df41..e9f47d8 100644 --- a/lla/Cargo.toml +++ b/lla/Cargo.toml @@ -2,11 +2,11 @@ name = "lla" version.workspace = true edition.workspace = true -description = "A lightweight ls replacement" +description = "Blazing Fast and highly customizable ls Replacement with Superpowers" authors.workspace = true license.workspace = true repository.workspace = true -keywords = ["cli", "ls", "lightweight"] +keywords = ["cli", "ls", "blazing-fast"] categories = ["command-line-utilities"] exclude = ["docs"] @@ -28,7 +28,8 @@ walkdir.workspace = true tempfile.workspace = true users.workspace = true parking_lot.workspace = true -lla_plugin_interface = { version = "0.3.8", path = "../lla_plugin_interface" } +lla_plugin_interface = { version = "0.3.9", path = "../lla_plugin_interface" } +lla_plugin_utils = { version = "0.3.9", path = "../lla_plugin_utils" } once_cell.workspace = true dashmap.workspace = true unicode-width.workspace = true @@ -46,3 +47,4 @@ crossterm = "0.27" crossbeam-channel = "0.5.14" unicode-normalization = "0.1.22" num_cpus = "1.16" +fuzzy-matcher = "0.3.7" diff --git a/lla/README.md b/lla/README.md index 52703ff..6cc0e4e 100644 --- a/lla/README.md +++ b/lla/README.md @@ -1,17 +1,26 @@ # `lla` - Blazing Fast `ls` Replacement with Superpowers -`lla` is a high-performance file explorer written in Rust that enhances the traditional `ls` command with modern features, rich formatting options, and a powerful plugin system. +

+

+ Logo +
lla +

+ +lla is a modern `ls` replacement that transforms how developers interact with their filesystem. Built with Rust's performance capabilities and designed with user experience in mind, lla combines the familiarity of `ls` with powerful features like specialized views, Git integration, and a robust plugin system with an extensible list of plugins to add more functionality. ![default](https://github.com/user-attachments/assets/ba5fa273-c2c4-4143-b199-ab5bff1bb608) ## Features -- Multiple display formats: default, fuzzy, long, tree, recursive, table, grid, git-aware, timeline, and sizemap views -- Advanced sorting and filtering capabilities -- Powerful plugin system for extended functionality -- Customizable themes -- Git integration -- Performance optimized for large directories +- Multiple Views: Default clean view, long format, tree structure, table layout, grid display +- Git Integration: Built-in status visualization and repository insights +- Advanced Organization: Timeline view, storage analysis, recursive exploration +- Smart Search: complex filtering patterns (OR, AND, NOT, XOR), regex support +- Customization: Plugin system, theme manager, custom shortcuts, configurable display +- High Performance: Built with Rust, modern UI, clean listings +- Smart Sorting: Multiple criteria, directory-first option, natural sorting +- Flexible Config: Easy initialization, plugin management, configuration tools +- Rich Plugin Ecosystem: File ops and metadata enhancements, code analysis, git tools, and more ## Installation @@ -21,4 +30,4 @@ cargo install lla ``` For more installation options, documentation, and detailed usage instructions, visit: -[lla Github repository](https://github.com/triyanox/lla) +[lla Github repository](https://github.com/chaqchase/lla) diff --git a/lla/src/commands/args.rs b/lla/src/commands/args.rs index 0d7330a..3027319 100644 --- a/lla/src/commands/args.rs +++ b/lla/src/commands/args.rs @@ -51,6 +51,8 @@ pub enum Command { Shortcut(ShortcutAction), GenerateCompletion(Shell, Option, Option), Theme, + ThemePull, + ThemeInstall(String), } pub enum InstallSource { @@ -406,7 +408,24 @@ impl Args { .takes_value(true), ), ) - .subcommand(SubCommand::with_name("theme").about("Interactive theme manager")) + .subcommand( + SubCommand::with_name("theme") + .about("Interactive theme manager") + .subcommand( + SubCommand::with_name("pull") + .about("Pull and install themes from the official repository") + ) + .subcommand( + SubCommand::with_name("install") + .about("Install theme(s) from a file or directory") + .arg( + Arg::with_name("path") + .help("Path to theme file or directory containing themes") + .required(true) + .index(1), + ) + ), + ) } pub fn parse(config: &Config) -> Self { @@ -478,8 +497,16 @@ impl Args { completion_matches.value_of("path").map(String::from), completion_matches.value_of("output").map(String::from), )) - } else if matches.subcommand_matches("theme").is_some() { - Some(Command::Theme) + } else if let Some(theme_matches) = matches.subcommand_matches("theme") { + if theme_matches.subcommand_matches("pull").is_some() { + Some(Command::ThemePull) + } else if let Some(install_matches) = theme_matches.subcommand_matches("install") { + Some(Command::ThemeInstall( + install_matches.value_of("path").unwrap().to_string(), + )) + } else { + Some(Command::Theme) + } } else if let Some(matches) = matches.subcommand_matches("shortcut") { if let Some(add_matches) = matches.subcommand_matches("add") { Some(Command::Shortcut(ShortcutAction::Add( diff --git a/lla/src/commands/command_handler.rs b/lla/src/commands/command_handler.rs index dbe14d3..622d7a7 100644 --- a/lla/src/commands/command_handler.rs +++ b/lla/src/commands/command_handler.rs @@ -149,6 +149,8 @@ pub fn handle_command( ) } Some(Command::Theme) => crate::theme::select_theme(config), + Some(Command::ThemePull) => crate::theme::pull_themes(&color_state), + Some(Command::ThemeInstall(path)) => crate::theme::install_themes(&path, &color_state), Some(Command::Shortcut(action)) => handle_shortcut_action(action, config, &color_state), Some(Command::Install(source)) => handle_install(source, args), Some(Command::Update(plugin_name)) => { diff --git a/lla/src/commands/plugin_utils.rs b/lla/src/commands/plugin_utils.rs index 7e9eb55..891ec81 100644 --- a/lla/src/commands/plugin_utils.rs +++ b/lla/src/commands/plugin_utils.rs @@ -2,83 +2,56 @@ use crate::config::Config; use crate::error::Result; use crate::plugin::PluginManager; use colored::*; -use dialoguer::{theme::ColorfulTheme, MultiSelect}; +use dialoguer::MultiSelect; +use lla_plugin_utils::ui::components::LlaDialoguerTheme; use std::collections::HashSet; pub fn list_plugins(plugin_manager: &mut PluginManager) -> Result<()> { - if atty::is(atty::Stream::Stdout) { - let plugins: Vec<(String, String, String)> = - plugin_manager.list_plugins().into_iter().collect(); + let plugins: Vec<(String, String, String)> = + plugin_manager.list_plugins().into_iter().collect(); - let plugin_names: Vec = plugins - .iter() - .map(|(name, version, desc)| { - format!( - "{} {} - {}", - name.cyan(), - format!("v{}", version).yellow(), - desc - ) - }) - .collect(); - - println!("\n{}", "Plugin Manager".cyan().bold()); - println!("{}\n", "Space to toggle, Enter to confirm".bright_black()); + let plugin_names: Vec = plugins + .iter() + .map(|(name, version, desc)| { + format!( + "{} {} - {}", + name.cyan(), + format!("v{}", version).yellow(), + desc + ) + }) + .collect(); - let theme = ColorfulTheme { - active_item_style: dialoguer::console::Style::new().cyan().bold(), - active_item_prefix: dialoguer::console::style("│ ⦿ ".to_string()) - .for_stderr() - .cyan(), - checked_item_prefix: dialoguer::console::style(" ◉ ".to_string()) - .for_stderr() - .green(), - unchecked_item_prefix: dialoguer::console::style(" ○ ".to_string()) - .for_stderr() - .red(), - prompt_prefix: dialoguer::console::style("│ ".to_string()) - .for_stderr() - .cyan(), - prompt_style: dialoguer::console::Style::new().for_stderr().cyan(), - success_prefix: dialoguer::console::style("│ ".to_string()) - .for_stderr() - .cyan(), - ..ColorfulTheme::default() - }; + let theme = LlaDialoguerTheme::default(); + let prompt = format!( + "{}\n{}\n\nSelect plugins", + "Plugin Manager".cyan().bold(), + "Space to toggle, Enter to confirm".bright_black() + ); - let selections = MultiSelect::with_theme(&theme) - .with_prompt("Select plugins") - .items(&plugin_names) - .defaults( - &plugins - .iter() - .map(|(name, _, _)| plugin_manager.enabled_plugins.contains(name)) - .collect::>(), - ) - .interact()?; + let selections = MultiSelect::with_theme(&theme) + .with_prompt(prompt) + .items(&plugin_names) + .defaults( + &plugins + .iter() + .map(|(name, _, _)| plugin_manager.enabled_plugins.contains(name)) + .collect::>(), + ) + .interact()?; - let mut updated_plugins = HashSet::new(); + let mut updated_plugins = HashSet::new(); - for idx in selections { - let (name, _, _) = &plugins[idx]; - updated_plugins.insert(name.to_string()); - } + for idx in selections { + let (name, _, _) = &plugins[idx]; + updated_plugins.insert(name.to_string()); + } - for (name, _, _) in &plugins { - if updated_plugins.contains(name) { - plugin_manager.enable_plugin(name)?; - } else { - plugin_manager.disable_plugin(name)?; - } - } - } else { - for (name, version, desc) in plugin_manager.list_plugins() { - println!( - "{} {} - {}", - name.cyan(), - format!("v{}", version).yellow(), - desc - ); + for (name, _, _) in &plugins { + if updated_plugins.contains(name) { + plugin_manager.enable_plugin(name)?; + } else { + plugin_manager.disable_plugin(name)?; } } diff --git a/lla/src/config/mod.rs b/lla/src/config/mod.rs index 2bb520a..a184d74 100644 --- a/lla/src/config/mod.rs +++ b/lla/src/config/mod.rs @@ -167,7 +167,7 @@ impl Config { fn generate_config_content(&self) -> String { let mut content = format!( - r#"# LLA Configuration File + r#"# lla Configuration File # This file controls the behavior and appearance of the lla command # Default sorting method for file listings diff --git a/lla/src/installer.rs b/lla/src/installer.rs index e1360d3..645960f 100644 --- a/lla/src/installer.rs +++ b/lla/src/installer.rs @@ -3,8 +3,9 @@ use crate::error::{LlaError, Result}; use crate::utils::color::ColorState; use colored::{ColoredString, Colorize}; use console::{style, Term}; -use dialoguer::{theme::ColorfulTheme, MultiSelect}; +use dialoguer::MultiSelect; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use lla_plugin_utils::ui::components::LlaDialoguerTheme; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; @@ -202,26 +203,7 @@ impl PluginInstaller { println!("\n{}", "Plugin Installation".cyan().bold()); println!("{}\n", "Space to toggle, Enter to confirm".bright_black()); - let theme = ColorfulTheme { - active_item_style: dialoguer::console::Style::new().cyan().bold(), - active_item_prefix: dialoguer::console::style("│ ⦿ ".to_string()) - .for_stderr() - .cyan(), - checked_item_prefix: dialoguer::console::style(" ◉ ".to_string()) - .for_stderr() - .green(), - unchecked_item_prefix: dialoguer::console::style(" ○ ".to_string()) - .for_stderr() - .red(), - prompt_prefix: dialoguer::console::style("│ ".to_string()) - .for_stderr() - .cyan(), - prompt_style: dialoguer::console::Style::new().for_stderr().cyan(), - success_prefix: dialoguer::console::style("│ ".to_string()) - .for_stderr() - .cyan(), - ..ColorfulTheme::default() - }; + let theme = LlaDialoguerTheme::default(); let selections = MultiSelect::with_theme(&theme) .with_prompt("Select plugins to install") diff --git a/lla/src/lister/fuzzy.rs b/lla/src/lister/fuzzy.rs index 255e553..aedee5a 100644 --- a/lla/src/lister/fuzzy.rs +++ b/lla/src/lister/fuzzy.rs @@ -11,10 +11,11 @@ use crossterm::{ style::{self}, terminal::{self, ClearType}, }; +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; use ignore::WalkBuilder; use parking_lot::RwLock; use rayon::prelude::*; -use std::collections::HashMap; use std::fs::Permissions; use std::io::{self, stdout, Write}; use std::os::unix::fs::PermissionsExt; @@ -25,30 +26,20 @@ use std::sync::{ }; use std::thread; use std::time::{Duration, SystemTime}; -use unicode_normalization::UnicodeNormalization; const WORKER_THREADS: usize = 8; const CHUNK_SIZE: usize = 1000; -const SCORE_MATCH: i32 = 16; -const SCORE_GAP_START: i32 = -3; -const SCORE_GAP_EXTENSION: i32 = -1; -const BONUS_BOUNDARY: i32 = SCORE_MATCH / 2; -const BONUS_NON_WORD: i32 = SCORE_MATCH / 2; -const BONUS_CAMEL: i32 = BONUS_BOUNDARY + SCORE_GAP_EXTENSION; -const BONUS_CONSECUTIVE: i32 = -(SCORE_GAP_START + SCORE_GAP_EXTENSION); -const BONUS_FIRST_CHAR_MULTIPLIER: i32 = 2; -const BONUS_BOUNDARY_WHITE: i32 = BONUS_BOUNDARY + 2; -const BONUS_BOUNDARY_DELIMITER: i32 = BONUS_BOUNDARY + 1; +const SEARCH_DEBOUNCE_MS: u64 = 50; +const RENDER_INTERVAL_MS: u64 = 16; -#[allow(dead_code)] #[derive(Clone)] +#[allow(dead_code)] struct FileEntry { path: PathBuf, path_str: String, name_str: String, modified: SystemTime, normalized_path: String, - score_cache: Arc)>>>, } impl FileEntry { @@ -58,7 +49,7 @@ impl FileEntry { .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); - let normalized_path = path_str.nfkd().collect::().to_lowercase(); + let normalized_path = path_str.to_lowercase(); Self { path_str, @@ -69,199 +60,21 @@ impl FileEntry { .and_then(|m| m.modified()) .unwrap_or_else(|_| SystemTime::now()), path, - score_cache: Arc::new(RwLock::new(HashMap::new())), } } } #[derive(Clone)] struct MatchResult { - score: i32, + score: i64, positions: Vec, entry: FileEntry, } -#[derive(Clone)] -struct FuzzyMatcher { - case_sensitive: bool, - pattern_cache: Arc>>>, - bonus_cache: Arc>>, -} - -impl FuzzyMatcher { - fn new(case_sensitive: bool) -> Self { - Self { - case_sensitive, - pattern_cache: Arc::new(RwLock::new(HashMap::new())), - bonus_cache: Arc::new(RwLock::new(HashMap::new())), - } - } - - fn get_cached_pattern(&self, pattern: &str) -> Vec { - if let Some(cached) = self.pattern_cache.read().get(pattern) { - return cached.clone(); - } - - let normalized = if !self.case_sensitive { - pattern.to_lowercase() - } else { - pattern.to_string() - }; - - let chars: Vec = normalized.chars().collect(); - self.pattern_cache - .write() - .insert(pattern.to_string(), chars.clone()); - chars - } - - fn compute_bonus(&self, prev_class: char, curr_class: char) -> i32 { - if let Some(&bonus) = self.bonus_cache.read().get(&(prev_class, curr_class)) { - return bonus; - } - - let bonus = match (prev_class, curr_class) { - (' ', c) if c.is_alphanumeric() => BONUS_BOUNDARY_WHITE, - ('/', c) | ('\\', c) | ('_', c) | ('-', c) | ('.', c) => { - if c.is_alphanumeric() { - BONUS_BOUNDARY_DELIMITER - } else { - BONUS_NON_WORD - } - } - (p, c) if !p.is_alphanumeric() && c.is_alphanumeric() => BONUS_BOUNDARY, - (p, c) if p.is_lowercase() && c.is_uppercase() => BONUS_CAMEL, - (p, c) if !p.is_numeric() && c.is_numeric() => BONUS_CAMEL, - (_, c) if !c.is_alphanumeric() => BONUS_NON_WORD, - _ => 0, - }; - - self.bonus_cache - .write() - .insert((prev_class, curr_class), bonus); - bonus - } - - fn fuzzy_match(&self, text: &str, pattern: &str) -> Option<(i32, Vec)> { - if pattern.is_empty() { - return Some((0, vec![])); - } - - let text = if !self.case_sensitive { - text.to_lowercase() - } else { - text.to_string() - }; - - let text_chars: Vec = text.chars().collect(); - let pattern_chars = self.get_cached_pattern(pattern); - - let m = pattern_chars.len(); - let n = text_chars.len(); - - if m > n { - return None; - } - - let first_char = pattern_chars[0]; - if !text_chars.contains(&first_char) { - return None; - } - - let mut dp = vec![vec![0; n]; m]; - let mut pos = vec![vec![0; n]; m]; - let mut matches = vec![false; n]; - let mut consecutive = vec![0; n]; - - let mut found_first = false; - for (j, &tc) in text_chars.iter().enumerate() { - if tc == first_char { - let bonus = if j == 0 { - BONUS_BOUNDARY_WHITE - } else { - self.compute_bonus(text_chars[j - 1], tc) - }; - dp[0][j] = SCORE_MATCH + bonus * BONUS_FIRST_CHAR_MULTIPLIER; - matches[j] = true; - consecutive[j] = 1; - found_first = true; - } else if found_first { - dp[0][j] = dp[0][j - 1] + SCORE_GAP_EXTENSION; - } - } - - if !found_first { - return None; - } - - for i in 1..m { - let mut prev_score = 0; - let mut prev_j = 0; - let curr_char = pattern_chars[i]; - - for j in i..n { - #[allow(unused_assignments)] - let mut score = 0; - if text_chars[j] == curr_char { - let bonus = if j == 0 { - BONUS_BOUNDARY_WHITE - } else { - self.compute_bonus(text_chars[j - 1], text_chars[j]) - }; - - let consec = if j > 0 && matches[j - 1] { - consecutive[j - 1] + 1 - } else { - 1 - }; - consecutive[j] = consec; - - score = dp[i - 1][j - 1] + SCORE_MATCH; - if consec > 1 { - score += BONUS_CONSECUTIVE * (consec - 1); - } - score += bonus; - - matches[j] = true; - prev_j = j; - } else { - score = prev_score + SCORE_GAP_EXTENSION; - consecutive[j] = 0; - } - - dp[i][j] = score; - pos[i][j] = if matches[j] { j } else { prev_j }; - prev_score = score; - } - } - - let mut positions = Vec::with_capacity(m); - let mut j = n - 1; - for _i in (0..m).rev() { - while j > 0 && !matches[j] { - j -= 1; - } - if matches[j] { - positions.push(j); - if j > 0 { - j -= 1; - } else { - break; - } - } else { - break; - } - } - positions.reverse(); - - Some((dp[m - 1][n - 1], positions)) - } -} - #[derive(Clone)] struct SearchIndex { - entries: Arc>>, - matcher: FuzzyMatcher, + entries: Arc>>, + matcher: Arc, last_query: Arc>, last_results: Arc>>, config: crate::config::Config, @@ -270,8 +83,8 @@ struct SearchIndex { impl SearchIndex { fn new(config: crate::config::Config) -> Self { Self { - entries: Arc::new(RwLock::new(Vec::new())), - matcher: FuzzyMatcher::new(false), + entries: Arc::new(RwLock::new(Vec::with_capacity(10000))), + matcher: Arc::new(SkimMatcherV2::default().ignore_case()), last_query: Arc::new(RwLock::new(String::new())), last_results: Arc::new(RwLock::new(Vec::new())), config, @@ -279,6 +92,10 @@ impl SearchIndex { } fn should_ignore_path(&self, path: &std::path::Path) -> bool { + if self.config.listers.fuzzy.ignore_patterns.is_empty() { + return false; + } + let path_str = path.to_string_lossy(); self.config .listers @@ -304,20 +121,28 @@ impl SearchIndex { }) } - fn add_entries(&self, new_entries: Vec) { + fn add_entries(&self, new_entries: Vec) -> bool { + let filtered: Vec<_> = new_entries + .into_iter() + .filter(|entry| !self.should_ignore_path(&entry.path)) + .collect(); + + if filtered.is_empty() { + return false; + } + let mut entries = self.entries.write(); - entries.extend( - new_entries - .into_iter() - .filter(|entry| !self.should_ignore_path(&entry.path)), - ); + entries.extend(filtered); + true } fn search(&self, query: &str, max_results: usize) -> Vec { + let entries = self.entries.read(); + if query.is_empty() { - let entries = self.entries.read(); let mut results: Vec<_> = entries .iter() + .take(max_results) .map(|entry| MatchResult { score: 0, positions: vec![], @@ -325,14 +150,7 @@ impl SearchIndex { }) .collect(); - results.par_sort_unstable_by(|a, b| { - a.entry - .name_str - .len() - .cmp(&b.entry.name_str.len()) - .then_with(|| a.entry.name_str.cmp(&b.entry.name_str)) - }); - results.truncate(max_results); + results.par_sort_unstable_by(|a, b| a.entry.name_str.cmp(&b.entry.name_str)); return results; } @@ -343,13 +161,21 @@ impl SearchIndex { if !cached_results.is_empty() { let filtered: Vec<_> = cached_results .iter() + .take(max_results * 2) .filter_map(|result| { self.matcher .fuzzy_match(&result.entry.normalized_path, query) - .map(|(score, positions)| MatchResult { - score, - positions, - entry: result.entry.clone(), + .map(|score| { + let positions = self + .matcher + .fuzzy_indices(&result.entry.normalized_path, query) + .map(|(_, indices)| indices) + .unwrap_or_default(); + MatchResult { + score, + positions, + entry: result.entry.clone(), + } }) }) .collect(); @@ -368,38 +194,27 @@ impl SearchIndex { } } - let entries = self.entries.read(); let chunk_size = (entries.len() / WORKER_THREADS).max(CHUNK_SIZE); - let results: Vec<_> = entries .par_chunks(chunk_size) .flat_map(|chunk| { chunk .iter() .filter_map(|entry| { - if let Some((score, positions)) = entry.score_cache.read().get(query) { - return Some(MatchResult { - score: *score, - positions: positions.clone(), - entry: entry.clone(), - }); - } - - if let Some((score, positions)) = - self.matcher.fuzzy_match(&entry.normalized_path, query) - { - entry - .score_cache - .write() - .insert(query.to_string(), (score, positions.clone())); - Some(MatchResult { - score, - positions, - entry: entry.clone(), + self.matcher + .fuzzy_match(&entry.normalized_path, query) + .map(|score| { + let positions = self + .matcher + .fuzzy_indices(&entry.normalized_path, query) + .map(|(_, indices)| indices) + .unwrap_or_default(); + MatchResult { + score, + positions, + entry: entry.clone(), + } }) - } else { - None - } }) .collect::>() }) @@ -477,10 +292,12 @@ impl FuzzyLister { walker.run(|| { let tx = tx.clone(); + let total_indexed = Arc::clone(&total_indexed_clone); Box::new(move |entry| { if let Ok(entry) = entry { if entry.file_type().map_or(false, |ft| ft.is_file()) { let _ = tx.send(FileEntry::new(entry.into_path())); + total_indexed.fetch_add(1, AtomicOrdering::SeqCst); } } ignore::WalkState::Continue @@ -491,14 +308,12 @@ impl FuzzyLister { while let Ok(entry) = rx.recv() { batch.push(entry); if batch.len() >= 1000 { - total_indexed_clone.fetch_add(batch.len(), AtomicOrdering::SeqCst); let _ = sender.send(batch); batch = Vec::with_capacity(1000); } } if !batch.is_empty() { - total_indexed_clone.fetch_add(batch.len(), AtomicOrdering::SeqCst); let _ = sender.send(batch); } @@ -506,35 +321,50 @@ impl FuzzyLister { }); let mut last_query = String::new(); - let mut last_update = std::time::Instant::now(); + let mut last_query_time = std::time::Instant::now(); let mut last_render = std::time::Instant::now(); - let mut last_status_update = std::time::Instant::now(); - let mut needs_render = true; - let mut initial_load_done = false; + let mut last_render_request = std::time::Instant::now(); + let mut last_batch_check = std::time::Instant::now(); + let mut pending_search = false; + let mut pending_render = false; - let update_interval = Duration::from_millis(150); - let render_interval = Duration::from_millis(33); - let status_update_interval = Duration::from_millis(100); + let search_debounce = Duration::from_millis(SEARCH_DEBOUNCE_MS); + let render_debounce = Duration::from_millis(16); + let render_interval = Duration::from_millis(RENDER_INTERVAL_MS); + let batch_check_interval = Duration::from_millis(100); + + self.render_ui(&search_bar, &mut result_list)?; + let results = index.search("", 1000); + result_list.update_results(results); loop { let now = std::time::Instant::now(); - - while let Ok(batch) = receiver.try_recv() { - index.add_entries(batch); - let current_indexed = total_indexed.load(AtomicOrdering::SeqCst); - - if now.duration_since(last_status_update) >= status_update_interval { - result_list.total_indexed = current_indexed; - self.render_status_bar(&result_list)?; - last_status_update = now; + let should_check_batch = + !pending_search && now.duration_since(last_batch_check) >= batch_check_interval; + + if should_check_batch { + let mut received_new_files = false; + while let Ok(batch) = receiver.try_recv() { + if index.add_entries(batch) { + received_new_files = true; + } } - if !initial_load_done && current_indexed > 100 { - let results = index.search("", 1000); - result_list.update_results(results); - initial_load_done = true; - needs_render = true; + if received_new_files { + result_list.total_indexed = total_indexed.load(AtomicOrdering::SeqCst); + result_list.indexing_complete = indexing_complete.load(AtomicOrdering::SeqCst); + if !last_query.is_empty() { + let results = index.search(&last_query, 1000); + result_list.update_results(results); + } else { + let results = index.search("", 1000); + result_list.update_results(results); + } + pending_render = true; + last_render_request = now; } + + last_batch_check = now; } if event::poll(Duration::from_millis(1))? { @@ -550,40 +380,44 @@ impl FuzzyLister { } (KeyCode::Up, KeyModifiers::NONE) => { result_list.move_selection(-1); - needs_render = true; + pending_render = true; + last_render_request = now; } (KeyCode::Down, KeyModifiers::NONE) => { result_list.move_selection(1); - needs_render = true; + pending_render = true; + last_render_request = now; } _ => { if search_bar.handle_input(key.code, key.modifiers) { last_query = search_bar.query.clone(); - result_list.selected_idx = 0; - result_list.window_start = 0; - - let results = index.search(&last_query, 1000); - result_list.update_results(results); - needs_render = true; - last_update = now; + last_query_time = now; + pending_search = true; + pending_render = true; + last_render_request = now; } } } } } - if !last_query.is_empty() && now.duration_since(last_update) >= update_interval { + if pending_search && now.duration_since(last_query_time) >= search_debounce { let results = index.search(&last_query, 1000); - if result_list.update_results(results) { - needs_render = true; - } - last_update = now; + result_list.selected_idx = 0; + result_list.window_start = 0; + result_list.update_results(results); + pending_search = false; + pending_render = true; + last_render_request = now; } - if needs_render && now.duration_since(last_render) >= render_interval { - self.render_ui(&search_bar, &result_list)?; + if pending_render + && now.duration_since(last_render_request) >= render_debounce + && now.duration_since(last_render) >= render_interval + { + self.render_ui(&search_bar, &mut result_list)?; last_render = now; - needs_render = false; + pending_render = false; } thread::sleep(Duration::from_millis(1)); @@ -595,70 +429,31 @@ impl FuzzyLister { Ok(selected_paths) } - fn render_ui(&self, search_bar: &SearchBar, result_list: &ResultList) -> io::Result<()> { + fn render_ui(&self, search_bar: &SearchBar, result_list: &mut ResultList) -> io::Result<()> { let mut stdout = stdout(); let (width, height) = terminal::size()?; let available_height = height.saturating_sub(4) as usize; - static mut LAST_SEARCH_BAR: Option = None; - let search_bar_rendered = search_bar.render(width); - let should_render_search = unsafe { - if LAST_SEARCH_BAR.as_ref() != Some(&search_bar_rendered) { - LAST_SEARCH_BAR = Some(search_bar_rendered.clone()); - true - } else { - false - } - }; + execute!( + stdout, + cursor::MoveTo(0, 0), + terminal::Clear(ClearType::All), + style::Print(&search_bar.render(width)), + cursor::MoveTo(0, 1), + style::Print("─".repeat(width as usize).bright_black()) + )?; - if should_render_search { + let result_lines = result_list.render(width); + for (i, line) in result_lines.iter().take(available_height).enumerate() { execute!( stdout, - cursor::MoveTo(0, 0), - terminal::Clear(ClearType::CurrentLine), - style::Print(&search_bar_rendered), - cursor::MoveTo(0, 1), - terminal::Clear(ClearType::CurrentLine), - style::Print("─".repeat(width as usize).bright_black()) + cursor::MoveTo(0, (i + 2) as u16), + style::Print(line) )?; } - static mut LAST_RESULTS: Option> = None; - let result_lines = result_list.render(width); - - let should_render_full = unsafe { - if LAST_RESULTS.as_ref().map_or(true, |last| { - last.len() != result_lines.len() - || last.iter().zip(result_lines.iter()).any(|(a, b)| a != b) - }) { - LAST_RESULTS = Some(result_lines.clone()); - true - } else { - false - } - }; - - if should_render_full { - for i in 2..height.saturating_sub(1) { - execute!( - stdout, - cursor::MoveTo(0, i), - terminal::Clear(ClearType::CurrentLine) - )?; - } - - for (i, line) in result_lines.iter().take(available_height).enumerate() { - execute!( - stdout, - cursor::MoveTo(0, (i + 2) as u16), - style::Print(line) - )?; - } - } - - static mut LAST_STATUS: Option = None; let status_line = format!( - "{}{}{}", + "{}{}{}{}", " Total: ".bold(), result_list.results.len().to_string().yellow(), format!( @@ -667,58 +462,20 @@ impl FuzzyLister { (result_list.window_start + available_height).min(result_list.results.len()), result_list.total_indexed ) - .bright_black() - ); - - let should_render_status = unsafe { - if LAST_STATUS.as_ref() != Some(&status_line) { - LAST_STATUS = Some(status_line.clone()); - true + .bright_black(), + if !result_list.indexing_complete { + format!(" • {}", "Indexing...".bright_yellow()) } else { - false + "".to_string() } - }; - - if should_render_status { - execute!( - stdout, - cursor::MoveTo(0, height - 1), - terminal::Clear(ClearType::CurrentLine), - style::Print(&status_line) - )?; - } - - execute!( - stdout, - cursor::MoveTo((search_bar.cursor_pos + 4) as u16, 0) - )?; - - stdout.flush() - } - - fn render_status_bar(&self, result_list: &ResultList) -> io::Result<()> { - let mut stdout = stdout(); - let (_, height) = terminal::size()?; - let available_height = height.saturating_sub(4) as usize; - - let status_line = format!( - "{}{}{}", - " Total: ".bold(), - result_list.results.len().to_string().yellow(), - format!( - " (showing {}-{} of {})", - result_list.window_start + 1, - (result_list.window_start + available_height).min(result_list.results.len()), - result_list.total_indexed - ) - .bright_black() ); execute!( stdout, cursor::MoveTo(0, height - 1), terminal::Clear(ClearType::CurrentLine), - style::Print(&status_line) + style::Print(&status_line), + cursor::MoveTo((search_bar.cursor_pos + 4) as u16, 0) )?; stdout.flush() @@ -831,6 +588,7 @@ struct ResultList { window_start: usize, max_visible: usize, total_indexed: usize, + indexing_complete: bool, } impl ResultList { @@ -841,24 +599,19 @@ impl ResultList { window_start: 0, max_visible, total_indexed: 0, + indexing_complete: false, } } - fn get_selected(&self) -> Option<&MatchResult> { + fn get_selected(&mut self) -> Option<&MatchResult> { self.results.get(self.selected_idx) } fn update_results(&mut self, results: Vec) -> bool { - if self.results.len() != results.len() { - self.results = results; - self.selected_idx = self.selected_idx.min(self.results.len().saturating_sub(1)); - self.update_window(); - return true; - } - - let changed = self.results.iter().zip(results.iter()).any(|(a, b)| { - a.score != b.score || a.positions != b.positions || a.entry.path != b.entry.path - }); + let changed = self.results.len() != results.len() + || self.results.iter().zip(results.iter()).any(|(a, b)| { + a.score != b.score || a.positions != b.positions || a.entry.path != b.entry.path + }); if changed { self.results = results; @@ -885,7 +638,7 @@ impl ResultList { } } - fn render(&self, width: u16) -> Vec { + fn render(&mut self, width: u16) -> Vec { let theme = get_theme(); let max_width = width as usize; @@ -893,8 +646,11 @@ impl ResultList { return vec![format!( " {} {}", "".color(color_value_to_color(&theme.colors.directory)), - if self.total_indexed == 0 { - "Indexing files...".to_string() + if !self.indexing_complete { + format!( + "Indexing files... {} files found", + self.total_indexed.to_string().yellow() + ) } else { format!("No matches found (indexed {} files)", self.total_indexed) } @@ -924,25 +680,19 @@ impl ResultList { if components.len() <= 2 { path_str.to_string() } else { - let prefix = components[0..components.len() - 2] - .iter() - .map(|c| c.as_os_str().to_string_lossy()) - .collect::>(); - - if prefix.len() > 1 { - let first = prefix[0].to_string(); - let last = if prefix.len() > 2 { - prefix.last().unwrap().to_string() - } else { - prefix[1].to_string() - }; - format!("{}/.../{}", first, last) - } else { - prefix.join("/") + let mut path_parts = Vec::new(); + path_parts.push(components[0].as_os_str().to_string_lossy().to_string()); + if components.len() > 3 { + path_parts.push("...".to_string()); } - .chars() - .take(max_width.saturating_sub(30)) - .collect::() + path_parts.push( + components[components.len() - 2] + .as_os_str() + .to_string_lossy() + .to_string(), + ); + path_parts.push(file_name.to_string()); + path_parts.join("/") } } else { path_str.to_string() diff --git a/lla/src/theme/mod.rs b/lla/src/theme/mod.rs index 6b46d0b..4734292 100644 --- a/lla/src/theme/mod.rs +++ b/lla/src/theme/mod.rs @@ -1,12 +1,16 @@ use crate::config::Config; -use crate::error::Result; +use crate::error::{LlaError, Result}; +use crate::utils::color::ColorState; use colored::Color; use colored::*; -use dialoguer::{theme::ColorfulTheme, Select}; +use dialoguer::Select; +use lla_plugin_utils::ui::components::LlaDialoguerTheme; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; +use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; +use tempfile; static NO_COLOR: AtomicBool = AtomicBool::new(false); @@ -458,20 +462,7 @@ pub fn select_theme(config: &mut Config) -> Result<()> { }) .collect(); - let theme = ColorfulTheme { - active_item_style: dialoguer::console::Style::new().cyan().bold(), - active_item_prefix: dialoguer::console::style("│ ⦿ ".to_string()) - .for_stderr() - .cyan(), - prompt_prefix: dialoguer::console::style("│ ".to_string()) - .for_stderr() - .cyan(), - prompt_style: dialoguer::console::Style::new().for_stderr().cyan(), - success_prefix: dialoguer::console::style("│ ".to_string()) - .for_stderr() - .cyan(), - ..ColorfulTheme::default() - }; + let theme = LlaDialoguerTheme::default(); println!("\n{}", "Theme Manager".cyan().bold()); println!( @@ -493,3 +484,151 @@ pub fn select_theme(config: &mut Config) -> Result<()> { Ok(()) } + +pub fn pull_themes(color_state: &ColorState) -> Result<()> { + let config_dir = dirs::home_dir() + .ok_or_else(|| LlaError::Other("Could not find home directory".into()))? + .join(".config") + .join("lla"); + let themes_dir = config_dir.join("themes"); + + fs::create_dir_all(&themes_dir)?; + let temp_dir = tempfile::tempdir()?; + if color_state.is_enabled() { + println!("{}", "Pulling themes from repository...".cyan()); + } else { + println!("Pulling themes from repository..."); + } + + let status = Command::new("git") + .args(&[ + "clone", + "--depth=1", + "https://github.com/triyanox/lla.git", + temp_dir.path().to_str().unwrap(), + ]) + .status()?; + + if !status.success() { + return Err(LlaError::Other("Failed to clone repository".into())); + } + + let repo_themes_dir = temp_dir.path().join("themes"); + if repo_themes_dir.exists() { + for entry in fs::read_dir(repo_themes_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("toml") { + let dest_path = themes_dir.join(path.file_name().unwrap()); + fs::copy(&path, &dest_path)?; + if color_state.is_enabled() { + println!( + "✓ Installed theme: {}", + path.file_name().unwrap().to_string_lossy().green() + ); + } else { + println!( + "✓ Installed theme: {}", + path.file_name().unwrap().to_string_lossy() + ); + } + } + } + } + + if color_state.is_enabled() { + println!("\n{}", "Themes installed successfully!".green()); + println!("Use {} to select a theme", "lla theme".cyan()); + } else { + println!("\nThemes installed successfully!"); + println!("Use 'lla theme' to select a theme"); + } + + Ok(()) +} + +pub fn install_themes(path: &str, color_state: &ColorState) -> Result<()> { + let config_dir = dirs::home_dir() + .ok_or_else(|| LlaError::Other("Could not find home directory".into()))? + .join(".config") + .join("lla"); + let themes_dir = config_dir.join("themes"); + + // Create themes directory if it doesn't exist + fs::create_dir_all(&themes_dir)?; + + let path = std::path::Path::new(path); + if !path.exists() { + return Err(LlaError::Other(format!( + "Path does not exist: {}", + path.display() + ))); + } + + let mut installed_count = 0; + + if path.is_file() { + if path.extension().and_then(|s| s.to_str()) == Some("toml") { + let dest_path = themes_dir.join(path.file_name().unwrap()); + fs::copy(path, &dest_path)?; + if color_state.is_enabled() { + println!( + "✓ Installed theme: {}", + path.file_name().unwrap().to_string_lossy().green() + ); + } else { + println!( + "✓ Installed theme: {}", + path.file_name().unwrap().to_string_lossy() + ); + } + installed_count += 1; + } else { + return Err(LlaError::Other( + "Theme file must have .toml extension".into(), + )); + } + } else if path.is_dir() { + for entry in fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("toml") { + let dest_path = themes_dir.join(path.file_name().unwrap()); + fs::copy(&path, &dest_path)?; + if color_state.is_enabled() { + println!( + "✓ Installed theme: {}", + path.file_name().unwrap().to_string_lossy().green() + ); + } else { + println!( + "✓ Installed theme: {}", + path.file_name().unwrap().to_string_lossy() + ); + } + installed_count += 1; + } + } + } + + if installed_count > 0 { + if color_state.is_enabled() { + println!( + "\n{}", + format!("{} theme(s) installed successfully!", installed_count).green() + ); + println!("Use {} to select a theme", "lla theme".cyan()); + } else { + println!("\n{} theme(s) installed successfully!", installed_count); + println!("Use 'lla theme' to select a theme"); + } + } else { + if color_state.is_enabled() { + println!("{}", "No themes were installed".yellow()); + } else { + println!("No themes were installed"); + } + } + + Ok(()) +} diff --git a/lla/src/utils/icons.rs b/lla/src/utils/icons.rs index dc23f6e..1dd6964 100644 --- a/lla/src/utils/icons.rs +++ b/lla/src/utils/icons.rs @@ -1,3 +1,4 @@ +use crate::utils::color::colorize_file_name_with_icon; use colored::Colorize; use once_cell::sync::Lazy; use std::collections::HashMap; @@ -461,7 +462,9 @@ fn get_special_file_icon(filename: &str) -> Option<&'static str> { pub fn format_with_icon(path: &Path, name: String, show_icons: bool) -> String { if show_icons { - format!("{} {}", get_icon(path).bold(), name).to_string() + let icon_str = get_icon(path).bold(); + let with_icon = format!("{} {}", icon_str, name); + colorize_file_name_with_icon(path, with_icon).to_string() } else { name } diff --git a/lla_plugin_interface/Cargo.toml b/lla_plugin_interface/Cargo.toml index c4a7f1b..0745a9b 100644 --- a/lla_plugin_interface/Cargo.toml +++ b/lla_plugin_interface/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -keywords = ["cli", "ls", "lightweight"] +keywords = ["cli", "ls", "blazing-fast"] categories = ["command-line-utilities"] [dependencies] diff --git a/lla_plugin_utils/Cargo.toml b/lla_plugin_utils/Cargo.toml index a2d6459..2f794eb 100644 --- a/lla_plugin_utils/Cargo.toml +++ b/lla_plugin_utils/Cargo.toml @@ -7,7 +7,7 @@ authors.workspace = true license.workspace = true [dependencies] -lla_plugin_interface = { path = "../lla_plugin_interface", version = "0.3.8" } +lla_plugin_interface = { path = "../lla_plugin_interface", version = "0.3.9" } serde = { workspace = true } colored = { workspace = true } toml = { workspace = true } @@ -18,9 +18,9 @@ chrono = { workspace = true } users = { workspace = true } indicatif = { workspace = true } console = "0.15.8" +dialoguer = "0.11.0" syntect = { version = "5.1.0", optional = true } lazy_static = { version = "1.4", optional = true } -dialoguer = { version = "0.11.0", optional = true } [features] default = ["config", "ui", "format", "syntax", "interactive"] @@ -28,4 +28,4 @@ config = [] ui = [] format = [] syntax = ["syntect", "lazy_static"] -interactive = ["dialoguer"] +interactive = [] diff --git a/lla_plugin_utils/README.md b/lla_plugin_utils/README.md index fc11377..2cf2155 100644 --- a/lla_plugin_utils/README.md +++ b/lla_plugin_utils/README.md @@ -1,6 +1,6 @@ # lla_plugin_utils -Utility library for building LLA plugins. +Utility library for building lla plugins. ## Core Components diff --git a/lla_plugin_utils/src/lib.rs b/lla_plugin_utils/src/lib.rs index b1f8453..c6a81f0 100644 --- a/lla_plugin_utils/src/lib.rs +++ b/lla_plugin_utils/src/lib.rs @@ -9,7 +9,6 @@ pub use config::{ConfigManager, PluginConfig}; pub use syntax::CodeHighlighter; pub use ui::{ components::{BoxComponent, BoxStyle, HelpFormatter, KeyValue, List, Spinner}, - selector::InteractiveSelector, TextBlock, TextStyle, }; diff --git a/lla_plugin_utils/src/ui/components.rs b/lla_plugin_utils/src/ui/components.rs index 88a99c1..b8b8c54 100644 --- a/lla_plugin_utils/src/ui/components.rs +++ b/lla_plugin_utils/src/ui/components.rs @@ -1,4 +1,5 @@ use super::{TextBlock, TextStyle}; +use colored::Colorize; use indicatif::{ProgressBar, ProgressStyle}; use std::cmp; use std::time::Duration; @@ -414,3 +415,207 @@ impl BoxComponent { output } } + +pub struct LlaDialoguerTheme { + colors: std::collections::HashMap, + symbols: std::collections::HashMap, + padding: usize, +} + +impl LlaDialoguerTheme { + pub fn new(colors: std::collections::HashMap) -> Self { + Self { + colors, + symbols: Self::default_symbols(), + padding: 1, + } + } + + fn default_symbols() -> std::collections::HashMap { + let mut symbols = std::collections::HashMap::new(); + symbols.insert("error".to_string(), "✘".to_string()); + symbols.insert("success".to_string(), "✔".to_string()); + symbols.insert("pointer".to_string(), "➜".to_string()); + symbols.insert("unchecked".to_string(), "◯".to_string()); + symbols.insert("checked".to_string(), "◉".to_string()); + symbols.insert("separator".to_string(), "•".to_string()); + symbols.insert("prompt".to_string(), "⟩".to_string()); + symbols.insert("bullet".to_string(), " ".to_string()); + symbols.insert("warning".to_string(), "⚠".to_string()); + symbols.insert("info".to_string(), "ℹ".to_string()); + symbols.insert("gradient_sep".to_string(), "· · ·".to_string()); + symbols + } + + pub fn default() -> Self { + let mut colors = std::collections::HashMap::new(); + colors.insert("success".to_string(), "bright_green".to_string()); + colors.insert("info".to_string(), "cyan".to_string()); + colors.insert("error".to_string(), "red".to_string()); + colors.insert("path".to_string(), "yellow".to_string()); + colors.insert("prompt".to_string(), "bright_magenta".to_string()); + colors.insert("highlight".to_string(), "bright_white".to_string()); + colors.insert("inactive".to_string(), "bright_black".to_string()); + colors.insert("separator".to_string(), "bright_black".to_string()); + colors.insert("warning".to_string(), "yellow".to_string()); + colors.insert("accent".to_string(), "bright_blue".to_string()); + colors.insert("gradient1".to_string(), "bright_magenta".to_string()); + colors.insert("gradient2".to_string(), "magenta".to_string()); + colors.insert("gradient3".to_string(), "bright_black".to_string()); + Self::new(colors) + } + + pub fn with_symbols(mut self, symbols: std::collections::HashMap) -> Self { + self.symbols.extend(symbols); + self + } + + pub fn with_padding(mut self, padding: usize) -> Self { + self.padding = padding; + self + } + + fn get_color(&self, key: &str) -> &str { + self.colors.get(key).map(|s| s.as_str()).unwrap_or("white") + } + + fn get_symbol(&self, key: &str) -> &str { + self.symbols.get(key).map(|s| s.as_str()).unwrap_or("") + } + + fn format_gradient_separator(&self) -> String { + format!( + "{} {} {}", + "·".color(self.get_color("gradient1")), + "·".color(self.get_color("gradient2")), + "·".color(self.get_color("gradient3")) + ) + } + + fn format_select_prefix(&self, active: bool) -> String { + if active { + format!( + "{} {}", + self.get_symbol("pointer").color(self.get_color("accent")), + self.get_symbol("bullet").color(self.get_color("prompt")) + ) + } else { + " ".to_string() + } + } +} + +impl dialoguer::theme::Theme for LlaDialoguerTheme { + fn format_prompt(&self, f: &mut dyn std::fmt::Write, prompt: &str) -> std::fmt::Result { + write!( + f, + "{} {} {} ", + self.get_symbol("prompt").color(self.get_color("accent")), + prompt.color(self.get_color("prompt")).bold(), + self.format_gradient_separator() + ) + } + + fn format_error(&self, f: &mut dyn std::fmt::Write, err: &str) -> std::fmt::Result { + write!( + f, + "{} {} {}", + self.get_symbol("error").color(self.get_color("error")), + self.format_gradient_separator(), + err.color(self.get_color("error")).bold() + ) + } + + fn format_confirm_prompt( + &self, + f: &mut dyn std::fmt::Write, + prompt: &str, + default: Option, + ) -> std::fmt::Result { + let options = match default { + Some(true) => format!( + "[{}{}]", + "Y".color(self.get_color("accent")).bold(), + "/n".color(self.get_color("inactive")) + ), + Some(false) => format!( + "[{}{}]", + "y".color(self.get_color("inactive")), + "/N".color(self.get_color("accent")).bold() + ), + None => format!("[{}]", "y/n".color(self.get_color("accent")).bold()), + }; + write!( + f, + "{} {} {} ", + self.get_symbol("prompt").color(self.get_color("accent")), + prompt.color(self.get_color("prompt")).bold(), + options + ) + } + + fn format_select_prompt_item( + &self, + f: &mut dyn std::fmt::Write, + text: &str, + active: bool, + ) -> std::fmt::Result { + let padding = " ".repeat(self.padding); + if active { + write!( + f, + "{}{}{}", + padding, + self.format_select_prefix(active), + text.color(self.get_color("highlight")).bold() + ) + } else { + write!( + f, + "{}{}{}", + padding, + self.format_select_prefix(active), + text.color(self.get_color("inactive")) + ) + } + } + + fn format_multi_select_prompt_item( + &self, + f: &mut dyn std::fmt::Write, + text: &str, + checked: bool, + active: bool, + ) -> std::fmt::Result { + let padding = " ".repeat(self.padding); + let check_symbol = if checked { + format!( + "{}{}", + self.get_symbol("checked").color(self.get_color("success")), + "·".color(self.get_color("gradient2")) + ) + } else { + format!( + "{}{}", + self.get_symbol("unchecked") + .color(self.get_color("inactive")), + " ".color(self.get_color("gradient3")) + ) + }; + + let text_style = if active { + text.color(self.get_color("highlight")).bold() + } else { + text.color(self.get_color("inactive")) + }; + + write!( + f, + "{}{}{} {}", + padding, + self.format_select_prefix(active), + check_symbol, + text_style + ) + } +} diff --git a/lla_plugin_utils/src/ui/selector.rs b/lla_plugin_utils/src/ui/selector.rs index 0bd6e68..f4cefb7 100644 --- a/lla_plugin_utils/src/ui/selector.rs +++ b/lla_plugin_utils/src/ui/selector.rs @@ -1,115 +1,28 @@ -#[cfg(feature = "interactive")] -use dialoguer::{theme::ColorfulTheme, MultiSelect, Select}; +use super::components::LlaDialoguerTheme; +use dialoguer::{MultiSelect, Select}; -pub struct InteractiveSelector; +pub fn select_single( + prompt: &str, + items: &[T], + default: Option, +) -> Result { + let theme = LlaDialoguerTheme::default(); + let mut selector = Select::with_theme(&theme).with_prompt(prompt).items(items); -impl InteractiveSelector { - #[cfg(feature = "interactive")] - pub fn select_one( - items: &[T], - prompt: &str, - default: Option, - ) -> Result, String> { - Select::with_theme(&ColorfulTheme::default()) - .with_prompt(prompt) - .items(&items.iter().map(|i| i.to_string()).collect::>()) - .default(default.unwrap_or(0)) - .interact_opt() - .map_err(|e| format!("Failed to show selector: {}", e)) + if let Some(default_idx) = default { + selector = selector.default(default_idx); } - #[cfg(not(feature = "interactive"))] - pub fn select_one( - _items: &[T], - _prompt: &str, - _default: Option, - ) -> Result, String> { - Err("Interactive features are not enabled".to_string()) - } - - #[cfg(feature = "interactive")] - pub fn select_multiple( - items: &[T], - prompt: &str, - defaults: Option<&[bool]>, - ) -> Result, String> { - MultiSelect::with_theme(&ColorfulTheme::default()) - .with_prompt(prompt) - .items(&items.iter().map(|i| i.to_string()).collect::>()) - .defaults(defaults.unwrap_or(&vec![false; items.len()])) - .interact() - .map_err(|e| format!("Failed to show selector: {}", e)) - } - - #[cfg(not(feature = "interactive"))] - pub fn select_multiple( - _items: &[T], - _prompt: &str, - _defaults: Option<&[bool]>, - ) -> Result, String> { - Err("Interactive features are not enabled".to_string()) - } - - #[cfg(feature = "interactive")] - pub fn confirm(prompt: &str, default: bool) -> Result { - dialoguer::Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(prompt) - .default(default) - .interact() - .map_err(|e| format!("Failed to show prompt: {}", e)) - } - - #[cfg(not(feature = "interactive"))] - pub fn confirm(_prompt: &str, _default: bool) -> Result { - Err("Interactive features are not enabled".to_string()) - } - - #[cfg(feature = "interactive")] - pub fn input(prompt: &str) -> Result { - dialoguer::Input::::with_theme(&ColorfulTheme::default()) - .with_prompt(prompt) - .interact_text() - .map_err(|e| format!("Failed to get input: {}", e)) - } - - #[cfg(not(feature = "interactive"))] - pub fn input(_prompt: &str) -> Result { - Err("Interactive features are not enabled".to_string()) - } - - #[cfg(feature = "interactive")] - pub fn select_with_custom( - items: &[T], - prompt: &str, - custom_prompt: &str, - ) -> Result, String> { - let mut display_items = items.iter().map(|i| i.to_string()).collect::>(); - display_items.push("(Custom)".to_string()); - - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt(prompt) - .items(&display_items) - .default(0) - .interact() - .map_err(|e| format!("Failed to show selector: {}", e))?; - - if selection == items.len() { - dialoguer::Input::::with_theme(&ColorfulTheme::default()) - .with_prompt(custom_prompt) - .interact_text() - .map(Some) - .map_err(|e| format!("Failed to get input: {}", e)) - } else { - Ok(Some(items[selection].to_string())) - } - } + selector + .interact() + .map_err(|e| format!("Failed to show selector: {}", e)) +} - #[cfg(not(feature = "interactive"))] - pub fn select_with_custom( - _items: &[T], - _prompt: &str, - _custom_prompt: &str, - ) -> Result, String> { - Err("Interactive features are not enabled".to_string()) - } +pub fn select_multiple(prompt: &str, items: &[T]) -> Result, String> { + let theme = LlaDialoguerTheme::default(); + MultiSelect::with_theme(&theme) + .with_prompt(prompt) + .items(items) + .interact() + .map_err(|e| format!("Failed to show selector: {}", e)) } diff --git a/plugins.md b/plugins.md index fb16ed8..aedb38a 100644 --- a/plugins.md +++ b/plugins.md @@ -1,6 +1,6 @@ -# LLA Plugins +# lla Plugins -This document lists all available plugins for LLA and provides installation instructions. +This is a list of all the plugins available for lla. ## Installation @@ -10,331 +10,31 @@ You can install all plugins at once using: lla install --git https://github.com/triyanox/lla ``` -Or you can install individual plugins as described below. - -## Available Plugins - -- [categorizer](#categorizer): Categorizes files based on their extensions and metadata -- [code_complexity](#code_complexity): Analyzes code complexity using various metrics -- [code_snippet_extractor](#code_snippet_extractor): A plugin for extracting and managing code snippets -- [dirs_meta](#dirs_meta): Shows directories metadata -- [duplicate_file_detector](#duplicate_file_detector): A plugin for the LLA that detects duplicate files. -- [file_hash](#file_hash): Displays the hash of each file -- [file_meta](#file_meta): Displays the file metadata of each file -- [file_tagger](#file_tagger): A plugin for tagging files and filtering by tags -- [git_status](#git_status): Shows the git status of each file -- [keyword_search](#keyword_search): Searches file contents for user-specified keywords -- [last_git_commit](#last_git_commit): A plugin for the LLA that provides the last git commit hash -- [sizeviz](#sizeviz): File size visualizer plugin for LLA - -### categorizer - -**Description:** Categorizes files based on their extensions and metadata - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/categorizer/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/categorizer/ -``` - -2. Manual installation: - -```bash -git clone https://github.com/triyanox/lla -cd lla/plugins/categorizer/ -cargo build --release -``` - -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. - -### code_complexity - -**Description:** Analyzes code complexity using various metrics - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/code_complexity/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/code_complexity/ -``` - -2. Manual installation: - -```bash -git clone https://github.com/triyanox/lla -cd lla/plugins/code_complexity/ -cargo build --release -``` - -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. - -### code_snippet_extractor - -**Description:** A plugin for extracting and managing code snippets - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/code_snippet_extractor/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/code_snippet_extractor/ -``` - -2. Manual installation: - -```bash -git clone https://github.com/triyanox/lla -cd lla/plugins/code_snippet_extractor/ -cargo build --release -``` - -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. - -### dirs - -**Description:** Shows directories metadata - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/dirs/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/dirs/ -``` - -2. Manual installation: - -```bash -git clone https://github.com/triyanox/lla -cd lla/plugins/dirs/ -cargo build --release -``` - -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. - -### duplicate_file_detector - -**Description:** A plugin for the LLA that detects duplicate files. - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/duplicate_file_detector/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/duplicate_file_detector/ -``` - -2. Manual installation: - -```bash -git clone https://github.com/triyanox/lla -cd lla/plugins/duplicate_file_detector/ -cargo build --release -``` - -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. - -### file_hash - -**Description:** Displays the hash of each file - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/file_hash/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/file_hash/ -``` - -2. Manual installation: - -```bash -git clone https://github.com/triyanox/lla -cd lla/plugins/file_hash/ -cargo build --release -``` - -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. - -### file_meta - -**Description:** Displays the file metadata of each file - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/file_meta/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/file_meta/ -``` - -2. Manual installation: - -```bash -git clone https://github.com/triyanox/lla -cd lla/plugins/file_meta/ -cargo build --release -``` - -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. - -### file_tagger - -**Description:** A plugin for tagging files and filtering by tags - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/file_tagger/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/file_tagger/ -``` - -2. Manual installation: - -```bash -git clone https://github.com/triyanox/lla -cd lla/plugins/file_tagger/ -cargo build --release -``` - -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. - -### git_status - -**Description:** Shows the git status of each file - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/git_status/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/git_status/ -``` - -2. Manual installation: +or you can manually install them like this: ```bash git clone https://github.com/triyanox/lla -cd lla/plugins/git_status/ +cd lla/plugins/ cargo build --release ``` -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. +then copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your lla plugins directory. -### keyword_search - -**Description:** Searches file contents for user-specified keywords - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/keyword_search/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/keyword_search/ -``` - -2. Manual installation: - -```bash -git clone https://github.com/triyanox/lla -cd lla/plugins/keyword_search/ -cargo build --release -``` - -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. - -### last_git_commit - -**Description:** A plugin for the LLA that provides the last git commit hash - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/last_git_commit/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/last_git_commit/ -``` - -2. Manual installation: - -```bash -git clone https://github.com/triyanox/lla -cd lla/plugins/last_git_commit/ -cargo build --release -``` - -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. - -### sizeviz - -**Description:** File size visualizer plugin for LLA - -**Version:** 0.3.1 - -**Documentation:** [Documentation](plugins/sizeviz/README.md) - -**Installation Options:** - -1. Using LLA install command: - -```bash -lla install --dir path/to/lla/plugins/sizeviz/ -``` - -2. Manual installation: - -```bash -git clone https://github.com/triyanox/lla -cd lla/plugins/sizeviz/ -cargo build --release -``` +## Available Plugins -Then, copy the generated `.so`, `.dll`, or `.dylib` file from the `target/release` directory to your LLA plugins directory. +- [categorizer](https://github.com/chaqchase/lla/tree/main/plugins/categorizer): Categorizes files based on their extensions and metadata +- [code_complexity](https://github.com/chaqchase/lla/tree/main/plugins/code_complexity): Analyzes code complexity using various metrics +- [code_snippet_extractor](https://github.com/chaqchase/lla/tree/main/plugins/code_snippet_extractor): A plugin for extracting and managing code snippets +- [dirs_meta](https://github.com/chaqchase/lla/tree/main/plugins/dirs_meta): Shows directories metadata +- [duplicate_file_detector](https://github.com/chaqchase/lla/tree/main/plugins/duplicate_file_detector): A plugin for the lla that detects duplicate files. +- [file_hash](https://github.com/chaqchase/lla/tree/main/plugins/file_hash): Displays the hash of each file +- [file_meta](https://github.com/chaqchase/lla/tree/main/plugins/file_meta): Displays the file metadata of each file +- [file_tagger](https://github.com/chaqchase/lla/tree/main/plugins/file_tagger): A plugin for tagging files and filtering by tags +- [git_status](https://github.com/chaqchase/lla/tree/main/plugins/git_status): Shows the git status of each file +- [keyword_search](https://github.com/chaqchase/lla/tree/main/plugins/keyword_search): Searches file contents for user-specified keywords +- [last_git_commit](https://github.com/chaqchase/lla/tree/main/plugins/last_git_commit): A plugin for the lla that provides the last git commit hash +- [sizeviz](https://github.com/chaqchase/lla/tree/main/plugins/sizeviz): File size visualizer plugin for lla +- [file_mover](https://github.com/chaqchase/lla/tree/main/plugins/file_mover): A plugin that provides an intuitive clipboard-based interface for moving files and directories. +- [file_copier](https://github.com/chaqchase/lla/tree/main/plugins/file_copier): A plugin that provides an intuitive clipboard-based interface for copying files and directories. +- [file_remover](https://github.com/chaqchase/lla/tree/main/plugins/file_remover): A plugin that provides an interactive interface for safely removing files and directories. +- [file_organizer](https://github.com/chaqchase/lla/tree/main/plugins/file_organizer): A plugin for organizing files using various strategies diff --git a/plugins/categorizer/README.md b/plugins/categorizer/README.md index f8f09ea..64a1e8d 100644 --- a/plugins/categorizer/README.md +++ b/plugins/categorizer/README.md @@ -1,4 +1,4 @@ -# LLA Categorizer Plugin +# lla Categorizer Plugin File categorization plugin for `lla` that organizes files based on extensions, with hierarchical categorization support. diff --git a/plugins/code_complexity/README.md b/plugins/code_complexity/README.md index ddafe9e..90906a6 100644 --- a/plugins/code_complexity/README.md +++ b/plugins/code_complexity/README.md @@ -1,4 +1,4 @@ -# LLA Code Complexity Plugin +# lla Code Complexity Plugin A code analysis plugin for `lla` that performs real-time complexity analysis of source code. diff --git a/plugins/code_snippet_extractor/README.md b/plugins/code_snippet_extractor/README.md index 33bd162..571cac8 100644 --- a/plugins/code_snippet_extractor/README.md +++ b/plugins/code_snippet_extractor/README.md @@ -1,4 +1,4 @@ -# LLA Code Snippet Extractor Plugin +# lla Code Snippet Extractor Plugin A plugin for `lla` that extracts, organizes, and manages code snippets with metadata and search capabilities. diff --git a/plugins/code_snippet_extractor/src/lib.rs b/plugins/code_snippet_extractor/src/lib.rs index 646e7eb..3cde64e 100644 --- a/plugins/code_snippet_extractor/src/lib.rs +++ b/plugins/code_snippet_extractor/src/lib.rs @@ -2,14 +2,14 @@ use arboard::Clipboard; use base64::Engine as _; use chrono::{TimeZone, Utc}; use colored::Colorize; -use dialoguer::{theme::ColorfulTheme, Select}; +use dialoguer::{MultiSelect, Select}; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use lazy_static::lazy_static; use lla_plugin_interface::{Plugin, PluginRequest, PluginResponse}; use lla_plugin_utils::{ config::PluginConfig, - ui::components::{BoxComponent, BoxStyle, HelpFormatter}, + ui::components::{BoxComponent, BoxStyle, HelpFormatter, LlaDialoguerTheme}, ActionRegistry, BasePlugin, ConfigurablePlugin, ProtobufHandler, }; use parking_lot::RwLock; @@ -731,18 +731,21 @@ impl CodeSnippetExtractorPlugin { }) .collect(); - let selections = dialoguer::MultiSelect::with_theme(&ColorfulTheme::default()) - .with_prompt("Select snippets (Space to select, Enter to confirm)") + let theme = LlaDialoguerTheme::default(); + let selection = MultiSelect::with_theme(&theme) + .with_prompt( + "Select snippets (Space to select, Enter to confirm, Arrow keys to navigate)", + ) .items(&selection_items) .defaults(&vec![false; selection_items.len()]) .interact() .map_err(|e| format!("Failed to show selector: {}", e))?; - if selections.is_empty() { + if selection.is_empty() { return Ok(()); } - let selected_snippets: Vec<&CodeSnippet> = selections.iter().map(|&i| results[i]).collect(); + let selected_snippets: Vec<&CodeSnippet> = selection.iter().map(|&i| results[i]).collect(); let actions = vec![ "View snippets", @@ -752,7 +755,7 @@ impl CodeSnippetExtractorPlugin { "Set category", ]; - let action_selection = Select::with_theme(&ColorfulTheme::default()) + let action_selection = Select::with_theme(&LlaDialoguerTheme::default()) .with_prompt("Choose action") .items(&actions) .default(0) @@ -778,7 +781,7 @@ impl CodeSnippetExtractorPlugin { ); } 2 => { - let input = dialoguer::Input::::with_theme(&ColorfulTheme::default()) + let input = dialoguer::Input::::with_theme(&LlaDialoguerTheme::default()) .with_prompt("Enter tags (space-separated)") .interact_text() .map_err(|e| format!("Failed to get input: {}", e))?; @@ -793,7 +796,7 @@ impl CodeSnippetExtractorPlugin { ); } 3 => { - let input = dialoguer::Input::::with_theme(&ColorfulTheme::default()) + let input = dialoguer::Input::::with_theme(&LlaDialoguerTheme::default()) .with_prompt("Enter tags to remove (space-separated)") .interact_text() .map_err(|e| format!("Failed to get input: {}", e))?; @@ -812,7 +815,7 @@ impl CodeSnippetExtractorPlugin { let mut category_items = vec!["(None)".to_string(), "(New category)".to_string()]; category_items.extend(categories); - let category_selection = Select::with_theme(&ColorfulTheme::default()) + let category_selection = Select::with_theme(&LlaDialoguerTheme::default()) .with_prompt("Choose category") .items(&category_items) .default(0) @@ -823,7 +826,7 @@ impl CodeSnippetExtractorPlugin { 0 => None, 1 => { let input = - dialoguer::Input::::with_theme(&ColorfulTheme::default()) + dialoguer::Input::::with_theme(&LlaDialoguerTheme::default()) .with_prompt("Enter new category name") .interact_text() .map_err(|e| format!("Failed to get input: {}", e))?; diff --git a/plugins/dirs_meta/README.md b/plugins/dirs_meta/README.md index 47d98d0..f84748c 100644 --- a/plugins/dirs_meta/README.md +++ b/plugins/dirs_meta/README.md @@ -1,4 +1,4 @@ -# LLA Directory Metadata Plugin +# lla Directory Metadata Plugin Real-time directory statistics with intelligent caching. diff --git a/plugins/duplicate_file_detector/README.md b/plugins/duplicate_file_detector/README.md index 7c140a0..7661550 100644 --- a/plugins/duplicate_file_detector/README.md +++ b/plugins/duplicate_file_detector/README.md @@ -1,4 +1,4 @@ -# LLA Duplicate File Detector Plugin +# lla Duplicate File Detector Plugin A plugin for `lla` that identifies identical files using secure hash comparison. diff --git a/plugins/file_copier/Cargo.toml b/plugins/file_copier/Cargo.toml new file mode 100644 index 0000000..2e3a1c2 --- /dev/null +++ b/plugins/file_copier/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "file_copier" +version = "0.1.0" +edition = "2021" +description = "A plugin for lla that provides clipboard functionality for copying files and directories" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +lla_plugin_interface = { path = "../../lla_plugin_interface" } +lla_plugin_utils = { path = "../../lla_plugin_utils" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +dialoguer = "0.11" +colored = "2.0" +lazy_static = "1.4" +parking_lot = "0.12" +dirs = "5.0" diff --git a/plugins/file_copier/README.md b/plugins/file_copier/README.md new file mode 100644 index 0000000..d718d00 --- /dev/null +++ b/plugins/file_copier/README.md @@ -0,0 +1,119 @@ +# lla File Copier Plugin + +A plugin for `lla` that provides an intuitive clipboard-based interface for copying files and directories. + +## Features + +- **Clipboard Management**: Persistent clipboard for files and directories +- **Interactive Selection**: Multi-select interface for files and operations +- **Flexible Copying**: Copy all or selected items from clipboard +- **Path Flexibility**: Support for both current and specified directories +- **Safe Operations**: Validation and error handling for copy operations +- **User Interface**: Colored output and interactive menus + +## Configuration + +Config file: `~/.config/lla/cp_clipboard.json` + +```toml +[colors] +success = "bright_green" +info = "bright_blue" +error = "bright_red" +path = "bright_yellow" +``` + +## Usage + +### Basic Operations + +```bash +# Add files from current directory to clipboard +lla plugin --name file_copier --action add + +# Add files from a specific directory to clipboard +lla plugin --name file_copier --action add /path/to/source + +# Copy all files from clipboard to current directory +lla plugin --name file_copier --action copy-all + +# Copy all files from clipboard to specific directory +lla plugin --name file_copier --action copy-all /path/to/destination + +# Copy selected files from clipboard to current directory +lla plugin --name file_copier --action copy-partial + +# Copy selected files from clipboard to specific directory +lla plugin --name file_copier --action copy-partial /path/to/destination +``` + +### Clipboard Management + +```bash +# View clipboard contents with option to remove items +lla plugin --name file_copier --action show + +# Clear the clipboard +lla plugin --name file_copier --action clear + +# Show help information +lla plugin --name file_copier --action help +``` + +## Common Workflows + +### 1. Copying Files Between Directories (Using Explicit Paths) + +```bash +# Add files from source directory +lla plugin --name file_copier --action add /path/to/source +# Select files to copy using space, confirm with enter + +# Copy all files to target directory +lla plugin --name file_copier --action copy-all /path/to/target +``` + +### 2. Copying Files Using Current Directory Navigation + +```bash +# In source directory +cd /path/to/source +lla plugin --name file_copier --action add +# Select files to add to clipboard + +# Navigate to first target +cd /path/to/target1 +lla plugin --name file_copier --action copy-partial +# Select subset of files to copy here + +# Navigate to second target +cd /path/to/target2 +lla plugin --name file_copier --action copy-partial +# Select another subset of files to copy here +``` + +### 3. Mixed Workflow (Current and Explicit Paths) + +```bash +# Add files from current directory +lla plugin --name file_copier --action add +# Select files to add to clipboard + +# Copy selected files to a specific directory without changing location +lla plugin --name file_copier --action copy-partial /path/to/target +``` + +## Display Format + +``` +───────────────────────────────────── + File Copier Clipboard +───────────────────────────────────── + Current Items: + → /path/to/file1.txt + → /path/to/directory1 + → /path/to/file2.rs +───────────────────────────────────── + Use Space to select, Enter to confirm +───────────────────────────────────── +``` diff --git a/plugins/file_copier/src/lib.rs b/plugins/file_copier/src/lib.rs new file mode 100644 index 0000000..200dc2d --- /dev/null +++ b/plugins/file_copier/src/lib.rs @@ -0,0 +1,508 @@ +use colored::Colorize; +use dialoguer::MultiSelect; +use lazy_static::lazy_static; +use lla_plugin_interface::{Plugin, PluginRequest, PluginResponse}; +use lla_plugin_utils::{ + config::PluginConfig, + ui::components::{BoxComponent, BoxStyle, HelpFormatter, LlaDialoguerTheme}, + ActionRegistry, BasePlugin, ConfigurablePlugin, ProtobufHandler, +}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + fs, + ops::Deref, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CopierConfig { + #[serde(default = "default_colors")] + colors: std::collections::HashMap, +} + +fn default_colors() -> std::collections::HashMap { + let mut colors = std::collections::HashMap::new(); + colors.insert("success".to_string(), "bright_green".to_string()); + colors.insert("info".to_string(), "bright_blue".to_string()); + colors.insert("error".to_string(), "bright_red".to_string()); + colors.insert("path".to_string(), "bright_yellow".to_string()); + colors +} + +impl Default for CopierConfig { + fn default() -> Self { + Self { + colors: default_colors(), + } + } +} + +impl PluginConfig for CopierConfig {} + +#[derive(Default, Clone, Serialize, Deserialize)] +struct Clipboard { + items: HashSet, +} + +impl Clipboard { + fn add(&mut self, path: PathBuf) { + self.items.insert(path); + } + + fn remove(&mut self, path: &Path) { + self.items.remove(path); + } + + fn clear(&mut self) { + self.items.clear(); + } + + fn get_items(&self) -> &HashSet { + &self.items + } + + fn save(&self) -> Result<(), String> { + let clipboard_file = Self::get_clipboard_path(); + if let Some(parent) = clipboard_file.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?; + } + let content = + serde_json::to_string(self).map_err(|e| format!("Failed to serialize: {}", e))?; + fs::write(&clipboard_file, content).map_err(|e| format!("Failed to write file: {}", e))?; + Ok(()) + } + + fn load() -> Self { + let clipboard_file = Self::get_clipboard_path(); + if let Ok(content) = fs::read_to_string(clipboard_file) { + if let Ok(clipboard) = serde_json::from_str(&content) { + return clipboard; + } + } + Self::default() + } + + fn get_clipboard_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("lla") + .join("cp_clipboard.json") + } +} + +lazy_static! { + static ref ACTION_REGISTRY: RwLock = RwLock::new({ + let mut registry = ActionRegistry::new(); + + lla_plugin_utils::define_action!( + registry, + "add", + "add [path]", + "Add files/directories to clipboard from current or specified directory", + vec![ + "lla plugin --name file_copier --action add", + "lla plugin --name file_copier --action add /path/to/dir" + ], + |args| FileCopierPlugin::add_action(args) + ); + + lla_plugin_utils::define_action!( + registry, + "copy-all", + "copy-all [target_path]", + "Copy all items from clipboard to current or specified directory", + vec![ + "lla plugin --name file_copier --action copy-all", + "lla plugin --name file_copier --action copy-all /path/to/target" + ], + |args| FileCopierPlugin::copy_all_action(args) + ); + + lla_plugin_utils::define_action!( + registry, + "copy-partial", + "copy-partial [target_path]", + "Copy selected items from clipboard to current or specified directory", + vec![ + "lla plugin --name file_copier --action copy-partial", + "lla plugin --name file_copier --action copy-partial /path/to/target" + ], + |args| FileCopierPlugin::copy_partial_action(args) + ); + + lla_plugin_utils::define_action!( + registry, + "clear", + "clear", + "Clear the clipboard", + vec!["lla plugin --name file_copier --action clear"], + |_| FileCopierPlugin::clear_action() + ); + + lla_plugin_utils::define_action!( + registry, + "show", + "show", + "Show clipboard contents", + vec!["lla plugin --name file_copier --action show"], + |_| FileCopierPlugin::show_action() + ); + + lla_plugin_utils::define_action!( + registry, + "help", + "help", + "Show help information", + vec!["lla plugin --name file_copier --action help"], + |_| FileCopierPlugin::help_action() + ); + + registry + }); +} + +pub struct FileCopierPlugin { + base: BasePlugin, + clipboard: Clipboard, +} + +impl FileCopierPlugin { + pub fn new() -> Self { + let plugin_name = env!("CARGO_PKG_NAME"); + Self { + base: BasePlugin::with_name(plugin_name), + clipboard: Clipboard::load(), + } + } + + fn get_directory(path_arg: Option<&str>) -> Result { + match path_arg { + Some(path) => Ok(PathBuf::from(path)), + None => std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e)), + } + } + + fn copy_directory_recursively(src: &Path, dst: &Path) -> Result<(), String> { + if !src.is_dir() { + return Err(format!("{} is not a directory", src.display())); + } + + fs::create_dir_all(dst) + .map_err(|e| format!("Failed to create directory {}: {}", dst.display(), e))?; + + for entry in fs::read_dir(src) + .map_err(|e| format!("Failed to read directory {}: {}", src.display(), e))? + { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + let dest_path = dst.join(path.file_name().ok_or("Invalid file name")?); + + if path.is_dir() { + Self::copy_directory_recursively(&path, &dest_path)?; + } else { + fs::copy(&path, &dest_path).map_err(|e| { + format!( + "Failed to copy {} to {}: {}", + path.display(), + dest_path.display(), + e + ) + })?; + } + } + + Ok(()) + } + + fn copy_item(src: &Path, dst: &Path) -> Result<(), String> { + if src.is_dir() { + Self::copy_directory_recursively(src, dst) + } else { + fs::copy(src, dst).map_err(|e| { + format!( + "Failed to copy {} to {}: {}", + src.display(), + dst.display(), + e + ) + })?; + Ok(()) + } + } + + fn add_action(args: &[String]) -> Result<(), String> { + let mut plugin = Self::new(); + let dir = Self::get_directory(args.get(0).map(|s| s.as_str()))?; + + let entries = fs::read_dir(&dir) + .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))? + .filter_map(Result::ok) + .map(|e| e.path()) + .collect::>(); + + if entries.is_empty() { + println!( + "{} Directory is empty: {}", + "Info:".bright_blue(), + dir.display() + ); + return Ok(()); + } + + let items: Vec = entries.iter().map(|p| p.display().to_string()).collect(); + + let theme = LlaDialoguerTheme::default(); + let selections = MultiSelect::with_theme(&theme) + .with_prompt("Select items to copy") + .items(&items) + .interact() + .map_err(|e| format!("Failed to show selector: {}", e))?; + + if selections.is_empty() { + println!("{} No items selected", "Info:".bright_blue()); + return Ok(()); + } + + for &idx in &selections { + plugin.clipboard.add(entries[idx].clone()); + } + + plugin.clipboard.save()?; + + println!( + "{} Added {} items to clipboard from {}", + "Success:".bright_green(), + selections.len(), + dir.display() + ); + Ok(()) + } + + fn copy_all_action(args: &[String]) -> Result<(), String> { + let plugin = Self::new(); + let items = plugin.clipboard.get_items().clone(); + + if items.is_empty() { + println!("{} Clipboard is empty", "Info:".bright_blue()); + return Ok(()); + } + + let target_dir = Self::get_directory(args.get(0).map(|s| s.as_str()))?; + + for path in items { + let new_path = target_dir.join(path.file_name().ok_or("Invalid file name")?); + Self::copy_item(&path, &new_path)?; + } + + println!( + "{} Copied all items to {}", + "Success:".bright_green(), + target_dir.display() + ); + Ok(()) + } + + fn copy_partial_action(args: &[String]) -> Result<(), String> { + let plugin = Self::new(); + let items: Vec = plugin.clipboard.get_items().iter().cloned().collect(); + + if items.is_empty() { + println!("{} Clipboard is empty", "Info:".bright_blue()); + return Ok(()); + } + + let item_strings: Vec = items.iter().map(|p| p.display().to_string()).collect(); + + let theme = LlaDialoguerTheme::default(); + let selections = MultiSelect::with_theme(&theme) + .with_prompt("Select items to copy") + .items(&item_strings) + .interact() + .map_err(|e| format!("Failed to show selector: {}", e))?; + + if selections.is_empty() { + println!("{} No items selected", "Info:".bright_blue()); + return Ok(()); + } + + let target_dir = Self::get_directory(args.get(0).map(|s| s.as_str()))?; + + for &idx in &selections { + let path = &items[idx]; + let new_path = target_dir.join(path.file_name().ok_or("Invalid file name")?); + Self::copy_item(path, &new_path)?; + } + + println!( + "{} Copied {} items to {}", + "Success:".bright_green(), + selections.len(), + target_dir.display() + ); + Ok(()) + } + + fn clear_action() -> Result<(), String> { + let mut plugin = Self::new(); + plugin.clipboard.clear(); + plugin.clipboard.save()?; + println!("{} Clipboard cleared", "Success:".bright_green()); + Ok(()) + } + + fn show_action() -> Result<(), String> { + let mut plugin = Self::new(); + let items = plugin.clipboard.get_items(); + + if items.is_empty() { + println!("{} Clipboard is empty", "Info:".bright_blue()); + return Ok(()); + } + + let item_strings: Vec = items.iter().map(|p| p.display().to_string()).collect(); + + let theme = LlaDialoguerTheme::default(); + let selections = MultiSelect::with_theme(&theme) + .with_prompt("Select items to remove from clipboard") + .items(&item_strings) + .interact() + .map_err(|e| format!("Failed to show selector: {}", e))?; + + if !selections.is_empty() { + let items_vec: Vec = items.iter().cloned().collect(); + for &idx in &selections { + plugin.clipboard.remove(&items_vec[idx]); + } + plugin.clipboard.save()?; + println!( + "{} Removed {} items from clipboard", + "Success:".bright_green(), + selections.len() + ); + } + + Ok(()) + } + + fn help_action() -> Result<(), String> { + let mut help = HelpFormatter::new("File Copier".to_string()); + help.add_section("Description".to_string()).add_command( + "".to_string(), + "Copy files and directories using a clipboard-like interface".to_string(), + vec![], + ); + + help.add_section("Basic Commands".to_string()) + .add_command( + "add [path]".to_string(), + "Add files/directories to clipboard from current or specified directory" + .to_string(), + vec![ + "lla plugin --name file_copier --action add".to_string(), + "lla plugin --name file_copier --action add /path/to/dir".to_string(), + ], + ) + .add_command( + "copy-all [target_path]".to_string(), + "Copy all items from clipboard to current or specified directory".to_string(), + vec![ + "lla plugin --name file_copier --action copy-all".to_string(), + "lla plugin --name file_copier --action copy-all /path/to/target".to_string(), + ], + ) + .add_command( + "copy-partial [target_path]".to_string(), + "Copy selected items from clipboard to current or specified directory".to_string(), + vec![ + "lla plugin --name file_copier --action copy-partial".to_string(), + "lla plugin --name file_copier --action copy-partial /path/to/target" + .to_string(), + ], + ); + + help.add_section("Clipboard Management".to_string()) + .add_command( + "show".to_string(), + "Show clipboard contents with option to remove items".to_string(), + vec!["lla plugin --name file_copier --action show".to_string()], + ) + .add_command( + "clear".to_string(), + "Clear the clipboard".to_string(), + vec!["lla plugin --name file_copier --action clear".to_string()], + ); + + println!( + "{}", + BoxComponent::new(help.render(&CopierConfig::default().colors)) + .style(BoxStyle::Minimal) + .padding(1) + .render() + ); + Ok(()) + } +} + +impl Deref for FileCopierPlugin { + type Target = CopierConfig; + + fn deref(&self) -> &Self::Target { + self.base.config() + } +} + +impl Plugin for FileCopierPlugin { + fn handle_raw_request(&mut self, request: &[u8]) -> Vec { + match self.decode_request(request) { + Ok(request) => { + let response = match request { + PluginRequest::GetName => { + PluginResponse::Name(env!("CARGO_PKG_NAME").to_string()) + } + PluginRequest::GetVersion => { + PluginResponse::Version(env!("CARGO_PKG_VERSION").to_string()) + } + PluginRequest::GetDescription => { + PluginResponse::Description(env!("CARGO_PKG_DESCRIPTION").to_string()) + } + PluginRequest::GetSupportedFormats => { + PluginResponse::SupportedFormats(vec!["default".to_string()]) + } + PluginRequest::Decorate(entry) => PluginResponse::Decorated(entry), + PluginRequest::FormatField(_, _) => PluginResponse::FormattedField(None), + PluginRequest::PerformAction(action, args) => { + let result = ACTION_REGISTRY.read().handle(&action, &args); + PluginResponse::ActionResult(result) + } + }; + self.encode_response(response) + } + Err(e) => self.encode_error(&e), + } + } +} + +impl ConfigurablePlugin for FileCopierPlugin { + type Config = CopierConfig; + + fn config(&self) -> &Self::Config { + self.base.config() + } + + fn config_mut(&mut self) -> &mut Self::Config { + self.base.config_mut() + } +} + +impl ProtobufHandler for FileCopierPlugin {} + +lla_plugin_interface::declare_plugin!(FileCopierPlugin); + +impl Default for FileCopierPlugin { + fn default() -> Self { + Self::new() + } +} diff --git a/plugins/file_hash/README.md b/plugins/file_hash/README.md index 2777374..49a3f2a 100644 --- a/plugins/file_hash/README.md +++ b/plugins/file_hash/README.md @@ -1,4 +1,4 @@ -# LLA File Hash Plugin +# lla File Hash Plugin A high-performance file hashing plugin for `lla` that calculates secure cryptographic hashes (SHA-1 and SHA-256). diff --git a/plugins/file_meta/README.md b/plugins/file_meta/README.md index 2a083d8..bddf662 100644 --- a/plugins/file_meta/README.md +++ b/plugins/file_meta/README.md @@ -1,4 +1,4 @@ -# LLA File Metadata Plugin +# lla File Metadata Plugin A file metadata plugin for `lla` that provides comprehensive file information with rich formatting. diff --git a/plugins/file_mover/Cargo.toml b/plugins/file_mover/Cargo.toml new file mode 100644 index 0000000..cc3e503 --- /dev/null +++ b/plugins/file_mover/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "file_mover" +version = "0.1.0" +edition = "2021" +description = "A plugin for lla that provides clipboard functionality for moving files and directories" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +lla_plugin_interface = { path = "../../lla_plugin_interface" } +lla_plugin_utils = { path = "../../lla_plugin_utils" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +dialoguer = "0.11" +colored = "2.0" +lazy_static = "1.4" +parking_lot = "0.12" +dirs = "5.0" diff --git a/plugins/file_mover/README.md b/plugins/file_mover/README.md new file mode 100644 index 0000000..7e0f753 --- /dev/null +++ b/plugins/file_mover/README.md @@ -0,0 +1,119 @@ +# lla File Mover Plugin + +A plugin for `lla` that provides an intuitive clipboard-based interface for moving files and directories. + +## Features + +- **Clipboard Management**: Persistent clipboard for files and directories +- **Interactive Selection**: Multi-select interface for files and operations +- **Flexible Moving**: Move all or selected items from clipboard +- **Path Flexibility**: Support for both current and specified directories +- **Safe Operations**: Validation and error handling for move operations +- **User Interface**: Colored output and interactive menus + +## Configuration + +Config file: `~/.config/lla/mv_clipboard.json` + +```toml +[colors] +success = "bright_green" +info = "bright_blue" +error = "bright_red" +path = "bright_yellow" +``` + +## Usage + +### Basic Operations + +```bash +# Add files from current directory to clipboard +lla plugin --name file_mover --action add + +# Add files from a specific directory to clipboard +lla plugin --name file_mover --action add /path/to/source + +# Move all files from clipboard to current directory +lla plugin --name file_mover --action move-all + +# Move all files from clipboard to specific directory +lla plugin --name file_mover --action move-all /path/to/destination + +# Move selected files from clipboard to current directory +lla plugin --name file_mover --action move-partial + +# Move selected files from clipboard to specific directory +lla plugin --name file_mover --action move-partial /path/to/destination +``` + +### Clipboard Management + +```bash +# View clipboard contents with option to remove items +lla plugin --name file_mover --action show + +# Clear the clipboard +lla plugin --name file_mover --action clear + +# Show help information +lla plugin --name file_mover --action help +``` + +## Common Workflows + +### 1. Moving Files Between Directories (Using Explicit Paths) + +```bash +# Add files from source directory +lla plugin --name file_mover --action add /path/to/source +# Select files to move using space, confirm with enter + +# Move all files to target directory +lla plugin --name file_mover --action move-all /path/to/target +``` + +### 2. Moving Files Using Current Directory Navigation + +```bash +# In source directory +cd /path/to/source +lla plugin --name file_mover --action add +# Select files to add to clipboard + +# Navigate to first target +cd /path/to/target1 +lla plugin --name file_mover --action move-partial +# Select subset of files to move here + +# Navigate to second target +cd /path/to/target2 +lla plugin --name file_mover --action move-partial +# Select another subset of files to move here +``` + +### 3. Mixed Workflow (Current and Explicit Paths) + +```bash +# Add files from current directory +lla plugin --name file_mover --action add +# Select files to add to clipboard + +# Move selected files to a specific directory without changing location +lla plugin --name file_mover --action move-partial /path/to/target +``` + +## Display Format + +``` +───────────────────────────────────── + File Mover Clipboard +───────────────────────────────────── + Current Items: + → /path/to/file1.txt + → /path/to/directory1 + → /path/to/file2.rs +───────────────────────────────────── + Use Space to select, Enter to confirm +───────────────────────────────────── +``` diff --git a/plugins/file_mover/src/lib.rs b/plugins/file_mover/src/lib.rs new file mode 100644 index 0000000..838ff4c --- /dev/null +++ b/plugins/file_mover/src/lib.rs @@ -0,0 +1,478 @@ +use colored::Colorize; +use dialoguer::MultiSelect; +use lazy_static::lazy_static; +use lla_plugin_interface::{Plugin, PluginRequest, PluginResponse}; +use lla_plugin_utils::{ + config::PluginConfig, + ui::components::{BoxComponent, BoxStyle, HelpFormatter, LlaDialoguerTheme}, + ActionRegistry, BasePlugin, ConfigurablePlugin, ProtobufHandler, +}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + fs, + ops::Deref, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MoverConfig { + #[serde(default = "default_colors")] + colors: std::collections::HashMap, +} + +fn default_colors() -> std::collections::HashMap { + let mut colors = std::collections::HashMap::new(); + colors.insert("success".to_string(), "bright_green".to_string()); + colors.insert("info".to_string(), "bright_blue".to_string()); + colors.insert("error".to_string(), "bright_red".to_string()); + colors.insert("path".to_string(), "bright_yellow".to_string()); + colors +} + +impl Default for MoverConfig { + fn default() -> Self { + Self { + colors: default_colors(), + } + } +} + +impl PluginConfig for MoverConfig {} + +#[derive(Default, Clone, Serialize, Deserialize)] +struct Clipboard { + items: HashSet, +} + +impl Clipboard { + fn add(&mut self, path: PathBuf) { + self.items.insert(path); + } + + fn remove(&mut self, path: &Path) { + self.items.remove(path); + } + + fn clear(&mut self) { + self.items.clear(); + } + + fn get_items(&self) -> &HashSet { + &self.items + } + + fn save(&self) -> Result<(), String> { + let clipboard_file = Self::get_clipboard_path(); + if let Some(parent) = clipboard_file.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?; + } + let content = + serde_json::to_string(self).map_err(|e| format!("Failed to serialize: {}", e))?; + fs::write(&clipboard_file, content).map_err(|e| format!("Failed to write file: {}", e))?; + Ok(()) + } + + fn load() -> Self { + let clipboard_file = Self::get_clipboard_path(); + if let Ok(content) = fs::read_to_string(clipboard_file) { + if let Ok(clipboard) = serde_json::from_str(&content) { + return clipboard; + } + } + Self::default() + } + + fn get_clipboard_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("lla") + .join("mv_clipboard.json") + } +} + +lazy_static! { + static ref ACTION_REGISTRY: RwLock = RwLock::new({ + let mut registry = ActionRegistry::new(); + + lla_plugin_utils::define_action!( + registry, + "add", + "add [path]", + "Add files/directories to clipboard from current or specified directory", + vec![ + "lla plugin --name file_mover --action add", + "lla plugin --name file_mover --action add /path/to/dir" + ], + |args| FileMoverPlugin::add_action(args) + ); + + lla_plugin_utils::define_action!( + registry, + "move-all", + "move-all [target_path]", + "Move all items from clipboard to current or specified directory", + vec![ + "lla plugin --name file_mover --action move-all", + "lla plugin --name file_mover --action move-all /path/to/target" + ], + |args| FileMoverPlugin::move_all_action(args) + ); + + lla_plugin_utils::define_action!( + registry, + "move-partial", + "move-partial [target_path]", + "Move selected items from clipboard to current or specified directory", + vec![ + "lla plugin --name file_mover --action move-partial", + "lla plugin --name file_mover --action move-partial /path/to/target" + ], + |args| FileMoverPlugin::move_partial_action(args) + ); + + lla_plugin_utils::define_action!( + registry, + "clear", + "clear", + "Clear the clipboard", + vec!["lla plugin --name file_mover --action clear"], + |_| FileMoverPlugin::clear_action() + ); + + lla_plugin_utils::define_action!( + registry, + "show", + "show", + "Show clipboard contents", + vec!["lla plugin --name file_mover --action show"], + |_| FileMoverPlugin::show_action() + ); + + lla_plugin_utils::define_action!( + registry, + "help", + "help", + "Show help information", + vec!["lla plugin --name file_mover --action help"], + |_| FileMoverPlugin::help_action() + ); + + registry + }); +} + +pub struct FileMoverPlugin { + base: BasePlugin, + clipboard: Clipboard, +} + +impl FileMoverPlugin { + pub fn new() -> Self { + let plugin_name = env!("CARGO_PKG_NAME"); + Self { + base: BasePlugin::with_name(plugin_name), + clipboard: Clipboard::load(), + } + } + + fn get_directory(path_arg: Option<&str>) -> Result { + match path_arg { + Some(path) => Ok(PathBuf::from(path)), + None => std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e)), + } + } + + fn add_action(args: &[String]) -> Result<(), String> { + let mut plugin = Self::new(); + let dir = Self::get_directory(args.get(0).map(|s| s.as_str()))?; + + let entries = fs::read_dir(&dir) + .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))? + .filter_map(Result::ok) + .map(|e| e.path()) + .collect::>(); + + if entries.is_empty() { + println!( + "{} Directory is empty: {}", + "Info:".bright_blue(), + dir.display() + ); + return Ok(()); + } + + let items: Vec = entries + .iter() + .map(|p| { + p.file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }) + .collect(); + + let theme = LlaDialoguerTheme::default(); + let selections = MultiSelect::with_theme(&theme) + .with_prompt("Select items to add to clipboard (Space to select, Enter to confirm)") + .items(&items) + .interact() + .map_err(|e| format!("Failed to show selector: {}", e))?; + + if selections.is_empty() { + println!("{} No items selected", "Info:".bright_blue()); + return Ok(()); + } + + for &idx in &selections { + plugin.clipboard.add(entries[idx].clone()); + } + + plugin.clipboard.save()?; + + println!( + "{} Added {} items to clipboard from {}", + "Success:".bright_green(), + selections.len(), + dir.display() + ); + Ok(()) + } + + fn move_all_action(args: &[String]) -> Result<(), String> { + let mut plugin = Self::new(); + let items = plugin.clipboard.get_items().clone(); + + if items.is_empty() { + println!("{} Clipboard is empty", "Info:".bright_blue()); + return Ok(()); + } + + let target_dir = Self::get_directory(args.get(0).map(|s| s.as_str()))?; + + for path in items { + let new_path = target_dir.join(path.file_name().ok_or("Invalid file name")?); + fs::rename(&path, &new_path) + .map_err(|e| format!("Failed to move {}: {}", path.display(), e))?; + } + + plugin.clipboard.clear(); + plugin.clipboard.save()?; + + println!( + "{} Moved all items to {}", + "Success:".bright_green(), + target_dir.display() + ); + Ok(()) + } + + fn move_partial_action(args: &[String]) -> Result<(), String> { + let mut plugin = Self::new(); + let items: Vec = plugin.clipboard.get_items().iter().cloned().collect(); + + if items.is_empty() { + println!("{} Clipboard is empty", "Info:".bright_blue()); + return Ok(()); + } + + let item_strings: Vec = items.iter().map(|p| p.display().to_string()).collect(); + + let theme = LlaDialoguerTheme::default(); + let selections = MultiSelect::with_theme(&theme) + .with_prompt("Select items to move") + .items(&item_strings) + .interact() + .map_err(|e| format!("Failed to show selector: {}", e))?; + + if selections.is_empty() { + println!("{} No items selected", "Info:".bright_blue()); + return Ok(()); + } + + let target_dir = Self::get_directory(args.get(0).map(|s| s.as_str()))?; + + for &idx in &selections { + let path = &items[idx]; + let new_path = target_dir.join(path.file_name().ok_or("Invalid file name")?); + fs::rename(path, &new_path) + .map_err(|e| format!("Failed to move {}: {}", path.display(), e))?; + plugin.clipboard.remove(path); + } + + plugin.clipboard.save()?; + + println!( + "{} Moved {} items to {}", + "Success:".bright_green(), + selections.len(), + target_dir.display() + ); + Ok(()) + } + + fn clear_action() -> Result<(), String> { + let mut plugin = Self::new(); + plugin.clipboard.clear(); + plugin.clipboard.save()?; + println!("{} Clipboard cleared", "Success:".bright_green()); + Ok(()) + } + + fn show_action() -> Result<(), String> { + let mut plugin = Self::new(); + let items = plugin.clipboard.get_items(); + + if items.is_empty() { + println!("{} Clipboard is empty", "Info:".bright_blue()); + return Ok(()); + } + + let item_strings: Vec = items.iter().map(|p| p.display().to_string()).collect(); + + let theme = LlaDialoguerTheme::default(); + let selections = MultiSelect::with_theme(&theme) + .with_prompt( + "Select items to remove from clipboard (Space to select, Enter to confirm)", + ) + .items(&item_strings) + .interact() + .map_err(|e| format!("Failed to show selector: {}", e))?; + + if !selections.is_empty() { + let items_vec: Vec = items.iter().cloned().collect(); + for &idx in &selections { + plugin.clipboard.remove(&items_vec[idx]); + } + plugin.clipboard.save()?; + println!( + "{} Removed {} items from clipboard", + "Success:".bright_green(), + selections.len() + ); + } + + Ok(()) + } + + fn help_action() -> Result<(), String> { + let mut help = HelpFormatter::new("File Mover".to_string()); + help.add_section("Description".to_string()).add_command( + "".to_string(), + "Move files and directories using a clipboard-like interface".to_string(), + vec![], + ); + + help.add_section("Basic Commands".to_string()) + .add_command( + "add [path]".to_string(), + "Add files/directories to clipboard from current or specified directory" + .to_string(), + vec![ + "lla plugin --name file_mover --action add".to_string(), + "lla plugin --name file_mover --action add /path/to/dir".to_string(), + ], + ) + .add_command( + "move-all [target_path]".to_string(), + "Move all items from clipboard to current or specified directory".to_string(), + vec![ + "lla plugin --name file_mover --action move-all".to_string(), + "lla plugin --name file_mover --action move-all /path/to/target".to_string(), + ], + ) + .add_command( + "move-partial [target_path]".to_string(), + "Move selected items from clipboard to current or specified directory".to_string(), + vec![ + "lla plugin --name file_mover --action move-partial".to_string(), + "lla plugin --name file_mover --action move-partial /path/to/target" + .to_string(), + ], + ); + + help.add_section("Clipboard Management".to_string()) + .add_command( + "show".to_string(), + "Show clipboard contents with option to remove items".to_string(), + vec!["lla plugin --name file_mover --action show".to_string()], + ) + .add_command( + "clear".to_string(), + "Clear the clipboard".to_string(), + vec!["lla plugin --name file_mover --action clear".to_string()], + ); + + println!( + "{}", + BoxComponent::new(help.render(&MoverConfig::default().colors)) + .style(BoxStyle::Minimal) + .padding(1) + .render() + ); + Ok(()) + } +} + +impl Deref for FileMoverPlugin { + type Target = MoverConfig; + + fn deref(&self) -> &Self::Target { + self.base.config() + } +} + +impl Plugin for FileMoverPlugin { + fn handle_raw_request(&mut self, request: &[u8]) -> Vec { + match self.decode_request(request) { + Ok(request) => { + let response = match request { + PluginRequest::GetName => { + PluginResponse::Name(env!("CARGO_PKG_NAME").to_string()) + } + PluginRequest::GetVersion => { + PluginResponse::Version(env!("CARGO_PKG_VERSION").to_string()) + } + PluginRequest::GetDescription => { + PluginResponse::Description(env!("CARGO_PKG_DESCRIPTION").to_string()) + } + PluginRequest::GetSupportedFormats => { + PluginResponse::SupportedFormats(vec!["default".to_string()]) + } + PluginRequest::Decorate(entry) => PluginResponse::Decorated(entry), + PluginRequest::FormatField(_, _) => PluginResponse::FormattedField(None), + PluginRequest::PerformAction(action, args) => { + let result = ACTION_REGISTRY.read().handle(&action, &args); + PluginResponse::ActionResult(result) + } + }; + self.encode_response(response) + } + Err(e) => self.encode_error(&e), + } + } +} + +impl ConfigurablePlugin for FileMoverPlugin { + type Config = MoverConfig; + + fn config(&self) -> &Self::Config { + self.base.config() + } + + fn config_mut(&mut self) -> &mut Self::Config { + self.base.config_mut() + } +} + +impl ProtobufHandler for FileMoverPlugin {} + +lla_plugin_interface::declare_plugin!(FileMoverPlugin); + +impl Default for FileMoverPlugin { + fn default() -> Self { + Self::new() + } +} diff --git a/plugins/file_organizer/Cargo.toml b/plugins/file_organizer/Cargo.toml new file mode 100644 index 0000000..7a05a41 --- /dev/null +++ b/plugins/file_organizer/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "file_organizer" +version = "0.1.0" +edition = "2021" +description = "A plugin for lla that organizes files using various strategies" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +lla_plugin_interface = { path = "../../lla_plugin_interface" } +lla_plugin_utils = { path = "../../lla_plugin_utils" } +colored = "2.0" +lazy_static = "1.4" +parking_lot = "0.12" +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +dirs = "5.0" +chrono = "0.4" diff --git a/plugins/file_organizer/README.md b/plugins/file_organizer/README.md new file mode 100644 index 0000000..2acdebc --- /dev/null +++ b/plugins/file_organizer/README.md @@ -0,0 +1,214 @@ +# lla File Organizer Plugin + +A plugin for `lla` that organizes files in directories using various strategies. It provides flexible, configurable organization methods with preview capabilities. + +## Features + +### Organization Strategies + +- **Extension Strategy** + + - Groups files by their extensions + - Optional nested categorization (e.g., `images/png`, `documents/pdf`) + - Handles case-insensitive extensions + +- **Date Strategy** + + - Organizes by file modification date + - Configurable grouping (year, month, day) + - Customizable date format patterns + +- **Type Strategy** + + - Predefined categories (documents, images, videos, etc.) + - Customizable category-to-extension mappings + - Smart file type detection + +- **Size Strategy** + - Organizes files into size-based categories + - Configurable size ranges + - Default ranges: tiny (0-100KB), small (100KB-1MB), medium (1MB-100MB), large (100MB-1GB), huge (>1GB) + +### Additional Features + +- Preview mode to review changes before applying +- Configurable ignore patterns for files and directories +- Color-coded output for better visibility +- Detailed error reporting + +## Configuration + +The plugin uses a TOML configuration file located at `~/.config/lla/file_organizer/config.toml`: + +```toml +[colors] +success = "bright_green" +info = "bright_blue" +error = "bright_red" +path = "bright_yellow" + +[extension] +enabled = true +create_nested = true # Create nested folders for similar types + +[date] +enabled = true +format = "%Y/%m/%d" # Folder structure format +group_by = "month" # year, month, or day + +[type_strategy] +enabled = true +categories = { + "documents" = ["pdf", "doc", "docx", "txt", "md"], + "images" = ["jpg", "jpeg", "png", "gif", "svg"], + "videos" = ["mp4", "mov", "avi", "mkv"], + "audio" = ["mp3", "wav", "flac", "m4a"], + "archives" = ["zip", "rar", "7z", "tar", "gz"] +} + +[size] +enabled = true +ranges = [ + { name = "tiny", max_bytes = 102400 }, # 0-100KB + { name = "small", max_bytes = 1048576 }, # 100KB-1MB + { name = "medium", max_bytes = 104857600 }, # 1MB-100MB + { name = "large", max_bytes = 1073741824 }, # 100MB-1GB + { name = "huge" } # >1GB +] + +[ignore] +patterns = [".git", "node_modules", "target"] +extensions = [".tmp", ".bak"] +``` + +## Usage + +### Basic Commands + +```bash +# Organize using default strategy (extension) +lla plugin --name file_organizer --action organize /path/to/dir + +# Organize using specific strategy +lla plugin --name file_organizer --action organize /path/to/dir extension +lla plugin --name file_organizer --action organize /path/to/dir date +lla plugin --name file_organizer --action organize /path/to/dir type +lla plugin --name file_organizer --action organize /path/to/dir size + +# Preview changes before organizing +lla plugin --name file_organizer --action preview /path/to/dir extension + +# Show help information +lla plugin --name file_organizer --action help +``` + +### Preview Format + +The preview command shows a detailed, color-coded view of planned changes: + +``` +📦 File Organization Preview +══════════════════════════════════════════════════ +Directory: /path/to/dir +Strategy: extension +══════════════════════════════════════════════════ + +📁 images/jpg + → vacation1.jpg + → family-photo.jpg + → screenshot.jpg + +📁 documents/pdf + → report.pdf + → invoice.pdf + +📁 audio/mp3 + → favorite-song.mp3 + → podcast.mp3 + +══════════════════════════════════════════════════ +Summary: 7 files will be organized into 3 directories +══════════════════════════════════════════════════ +``` + +The preview shows: + +- Current directory and selected strategy +- Files grouped by their target directories +- Clear arrows indicating file movements +- Summary of total files and directories +- Color-coded output for better readability + +### Example Results + +Each strategy organizes files differently: + +``` +# Extension Strategy (with nested = true) +/path/to/dir/ +├── images/ +│ ├── jpg/ +│ │ └── photo.jpg +│ └── png/ +│ └── screenshot.png +└── documents/ + ├── pdf/ + │ └── report.pdf + └── txt/ + └── notes.txt + +# Date Strategy (group_by = "month") +/path/to/dir/ +├── 2024/ +│ ├── 01/ +│ │ └── report.pdf +│ └── 02/ +│ └── photo.jpg +└── 2023/ + └── 12/ + └── notes.txt + +# Type Strategy +/path/to/dir/ +├── documents/ +│ ├── report.pdf +│ └── notes.txt +├── images/ +│ ├── photo.jpg +│ └── screenshot.png +└── audio/ + └── song.mp3 + +# Size Strategy +/path/to/dir/ +├── tiny/ +│ └── notes.txt +├── small/ +│ └── photo.jpg +└── medium/ + └── report.pdf +``` + +## Development + +The plugin is built with a modular architecture: + +- Each strategy implements the `OrganizationStrategy` trait +- Configuration is handled through serde-compatible structs +- Error handling with detailed messages +- Color-coded output using the `colored` crate + +### Adding New Strategies + +1. Create a new strategy module in `src/strategies/` +2. Implement the `OrganizationStrategy` trait +3. Add configuration structs in `src/config.rs` +4. Register the strategy in `src/lib.rs` + +## Building + +```bash +cargo build --release +``` + +The compiled plugin will be available in `target/release/libfile_organizer.so` (Linux/macOS) or `target/release/file_organizer.dll` (Windows). diff --git a/plugins/file_organizer/src/config.rs b/plugins/file_organizer/src/config.rs new file mode 100644 index 0000000..f2e0117 --- /dev/null +++ b/plugins/file_organizer/src/config.rs @@ -0,0 +1,255 @@ +use lla_plugin_utils::config::PluginConfig; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtensionStrategyConfig { + #[serde(default = "default_extension_enabled")] + pub enabled: bool, + #[serde(default = "default_extension_create_nested")] + pub create_nested: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DateStrategyConfig { + #[serde(default = "default_date_enabled")] + pub enabled: bool, + #[serde(default = "default_date_format")] + pub format: String, + #[serde(default = "default_date_group_by")] + pub group_by: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypeStrategyConfig { + #[serde(default = "default_type_enabled")] + pub enabled: bool, + #[serde(default = "default_type_categories")] + pub categories: HashMap>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SizeRange { + pub name: String, + pub max_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SizeStrategyConfig { + #[serde(default = "default_size_enabled")] + pub enabled: bool, + #[serde(default = "default_size_ranges")] + pub ranges: Vec, +} + +fn default_extension_enabled() -> bool { + true +} + +fn default_extension_create_nested() -> bool { + true +} + +fn default_date_enabled() -> bool { + true +} + +fn default_date_format() -> String { + "%Y/%m/%d".to_string() +} + +fn default_date_group_by() -> String { + "month".to_string() +} + +fn default_type_enabled() -> bool { + true +} + +fn default_type_categories() -> HashMap> { + let mut categories = HashMap::new(); + categories.insert( + "documents".to_string(), + vec![ + "pdf".to_string(), + "doc".to_string(), + "docx".to_string(), + "txt".to_string(), + "md".to_string(), + ], + ); + categories.insert( + "images".to_string(), + vec![ + "jpg".to_string(), + "jpeg".to_string(), + "png".to_string(), + "gif".to_string(), + "svg".to_string(), + ], + ); + categories.insert( + "videos".to_string(), + vec![ + "mp4".to_string(), + "mov".to_string(), + "avi".to_string(), + "mkv".to_string(), + ], + ); + categories.insert( + "audio".to_string(), + vec![ + "mp3".to_string(), + "wav".to_string(), + "flac".to_string(), + "m4a".to_string(), + ], + ); + categories.insert( + "archives".to_string(), + vec![ + "zip".to_string(), + "rar".to_string(), + "7z".to_string(), + "tar".to_string(), + "gz".to_string(), + ], + ); + categories +} + +fn default_size_enabled() -> bool { + true +} + +fn default_size_ranges() -> Vec { + vec![ + SizeRange { + name: "tiny".to_string(), + max_bytes: Some(102400), + }, // 0-100KB + SizeRange { + name: "small".to_string(), + max_bytes: Some(1048576), + }, // 100KB-1MB + SizeRange { + name: "medium".to_string(), + max_bytes: Some(104857600), + }, // 1MB-100MB + SizeRange { + name: "large".to_string(), + max_bytes: Some(1073741824), + }, // 100MB-1GB + SizeRange { + name: "huge".to_string(), + max_bytes: None, + }, // >1GB + ] +} + +impl Default for ExtensionStrategyConfig { + fn default() -> Self { + Self { + enabled: default_extension_enabled(), + create_nested: default_extension_create_nested(), + } + } +} + +impl Default for DateStrategyConfig { + fn default() -> Self { + Self { + enabled: default_date_enabled(), + format: default_date_format(), + group_by: default_date_group_by(), + } + } +} + +impl Default for TypeStrategyConfig { + fn default() -> Self { + Self { + enabled: default_type_enabled(), + categories: default_type_categories(), + } + } +} + +impl Default for SizeStrategyConfig { + fn default() -> Self { + Self { + enabled: default_size_enabled(), + ranges: default_size_ranges(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IgnoreConfig { + #[serde(default = "default_ignore_patterns")] + pub patterns: Vec, + #[serde(default = "default_ignore_extensions")] + pub extensions: Vec, +} + +fn default_ignore_patterns() -> Vec { + vec![ + ".git".to_string(), + "node_modules".to_string(), + "target".to_string(), + ] +} + +fn default_ignore_extensions() -> Vec { + vec![".tmp".to_string(), ".bak".to_string()] +} + +impl Default for IgnoreConfig { + fn default() -> Self { + Self { + patterns: default_ignore_patterns(), + extensions: default_ignore_extensions(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrganizerConfig { + #[serde(default = "default_colors")] + pub colors: HashMap, + #[serde(default)] + pub extension: ExtensionStrategyConfig, + #[serde(default)] + pub date: DateStrategyConfig, + #[serde(default)] + pub type_strategy: TypeStrategyConfig, + #[serde(default)] + pub size: SizeStrategyConfig, + #[serde(default)] + pub ignore: IgnoreConfig, +} + +fn default_colors() -> HashMap { + let mut colors = HashMap::new(); + colors.insert("success".to_string(), "bright_green".to_string()); + colors.insert("info".to_string(), "bright_blue".to_string()); + colors.insert("error".to_string(), "bright_red".to_string()); + colors.insert("path".to_string(), "bright_yellow".to_string()); + colors +} + +impl Default for OrganizerConfig { + fn default() -> Self { + Self { + colors: default_colors(), + extension: ExtensionStrategyConfig::default(), + date: DateStrategyConfig::default(), + type_strategy: TypeStrategyConfig::default(), + size: SizeStrategyConfig::default(), + ignore: IgnoreConfig::default(), + } + } +} + +impl PluginConfig for OrganizerConfig {} diff --git a/plugins/file_organizer/src/lib.rs b/plugins/file_organizer/src/lib.rs new file mode 100644 index 0000000..dc85024 --- /dev/null +++ b/plugins/file_organizer/src/lib.rs @@ -0,0 +1,297 @@ +mod config; +mod strategies; +use colored::Colorize; +use lazy_static::lazy_static; +use lla_plugin_interface::{Plugin, PluginRequest, PluginResponse}; +use lla_plugin_utils::{ + ui::components::{BoxComponent, BoxStyle, HelpFormatter}, + ActionRegistry, BasePlugin, ConfigurablePlugin, ProtobufHandler, +}; +use parking_lot::RwLock; +use std::{ops::Deref, path::PathBuf}; +use strategies::{ + DateStrategy, ExtensionStrategy, OrganizationStrategy, SizeStrategy, TypeStrategy, +}; + +use crate::config::OrganizerConfig; + +lazy_static! { + static ref ACTION_REGISTRY: RwLock = RwLock::new({ + let mut registry = ActionRegistry::new(); + + lla_plugin_utils::define_action!( + registry, + "organize", + "organize [strategy]", + "Organize files in the specified directory using the given strategy (defaults to extension)", + vec![ + "lla plugin --name file_organizer --action organize --args /path/to/dir", + "lla plugin --name file_organizer --action organize --args /path/to/dir extension", + "lla plugin --name file_organizer --action organize --args /path/to/dir date", + "lla plugin --name file_organizer --action organize --args /path/to/dir type", + "lla plugin --name file_organizer --action organize --args /path/to/dir size", + ], + |args| FileOrganizerPlugin::organize_action(args) + ); + + lla_plugin_utils::define_action!( + registry, + "preview", + "preview [strategy]", + "Preview organization changes without applying them", + vec![ + "lla plugin --name file_organizer --action preview --args /path/to/dir", + "lla plugin --name file_organizer --action preview --args /path/to/dir extension", + "lla plugin --name file_organizer --action preview --args /path/to/dir date", + "lla plugin --name file_organizer --action preview --args /path/to/dir type", + "lla plugin --name file_organizer --action preview --args /path/to/dir size", + ], + |args| FileOrganizerPlugin::preview_action(args) + ); + + lla_plugin_utils::define_action!( + registry, + "help", + "help", + "Show help information", + vec!["lla plugin --name file_organizer --action help"], + |_| FileOrganizerPlugin::help_action() + ); + + registry + }); +} + +pub struct FileOrganizerPlugin { + base: BasePlugin, +} + +impl FileOrganizerPlugin { + pub fn new() -> Self { + let plugin_name = env!("CARGO_PKG_NAME"); + let plugin = Self { + base: BasePlugin::with_name(plugin_name), + }; + if let Err(e) = plugin.base.save_config() { + eprintln!("[FileOrganizerPlugin] Failed to save config: {}", e); + } + plugin + } + + fn get_strategy(&self, strategy_name: Option<&str>) -> Box { + match strategy_name.unwrap_or("extension") { + "extension" => Box::new(ExtensionStrategy::new(self.config().extension.clone())), + "date" => Box::new(DateStrategy::new(self.config().date.clone())), + "type" => Box::new(TypeStrategy::new(self.config().type_strategy.clone())), + "size" => Box::new(SizeStrategy::new(self.config().size.clone())), + _ => Box::new(ExtensionStrategy::new(self.config().extension.clone())), + } + } + + fn organize_action(args: &[String]) -> Result<(), String> { + if args.is_empty() { + return Err("Usage: organize [strategy]".to_string()); + } + + let plugin = Self::new(); + let dir = PathBuf::from(&args[0]); + let strategy_name = args.get(1).map(|s| s.as_str()); + let strategy = plugin.get_strategy(strategy_name); + + let moves = strategy.organize(&dir, false)?; + if moves.is_empty() { + println!("{} No files to organize", "Info:".bright_blue()); + return Ok(()); + } + + strategy.execute_moves(moves)?; + println!( + "{} Successfully organized files in {}", + "Success:".bright_green(), + dir.display().to_string().bright_yellow() + ); + Ok(()) + } + + fn preview_action(args: &[String]) -> Result<(), String> { + if args.is_empty() { + return Err("Usage: preview [strategy]".to_string()); + } + + let plugin = Self::new(); + let dir = PathBuf::from(&args[0]); + let strategy_name = args.get(1).map(|s| s.as_str()); + let strategy = plugin.get_strategy(strategy_name); + + let moves = strategy.organize(&dir, true)?; + if moves.is_empty() { + println!("{} No files to organize", "Info:".bright_blue()); + return Ok(()); + } + + let mut moves_by_dir: std::collections::HashMap> = + std::collections::HashMap::new(); + + for (source, target) in moves { + let parent = target.parent().unwrap_or(&target).to_path_buf(); + moves_by_dir + .entry(parent) + .or_default() + .push((source, target)); + } + + let total_files: usize = moves_by_dir.values().map(|v| v.len()).sum(); + let total_dirs = moves_by_dir.len(); + + println!("\n{}", "📦 File Organization Preview".bright_cyan().bold()); + println!("{}", "═".bright_black().repeat(50)); + + println!( + "{} {}", + "Directory:".bright_yellow(), + dir.display().to_string().bright_white() + ); + println!( + "{} {}", + "Strategy:".bright_yellow(), + strategy_name.unwrap_or("extension").bright_white() + ); + println!("{}", "═".bright_black().repeat(50)); + + for (target_dir, moves) in &moves_by_dir { + let relative_dir = target_dir.strip_prefix(&dir).unwrap_or(&target_dir); + println!( + "\n{} {}", + "📁".bright_blue(), + relative_dir.display().to_string().bright_cyan() + ); + + for (source, _) in moves { + let file_name = source.file_name().unwrap_or_default().to_string_lossy(); + println!(" {} {}", "→".bright_green(), file_name.bright_white()); + } + } + + println!("\n{}", "═".bright_black().repeat(50)); + println!( + "{} {} files will be organized into {} directories", + "Summary:".bright_yellow(), + total_files.to_string().bright_white(), + total_dirs.to_string().bright_white() + ); + println!("{}", "═".bright_black().repeat(50)); + + Ok(()) + } + + fn help_action() -> Result<(), String> { + let mut help = HelpFormatter::new("File Organizer".to_string()); + help.add_section("Description".to_string()).add_command( + "".to_string(), + "Organize files in directories using various strategies".to_string(), + vec![], + ); + + help.add_section("Basic Commands".to_string()) + .add_command( + "organize".to_string(), + "Organize files in the specified directory".to_string(), + vec![ + "lla plugin --name file_organizer --action organize /path/to/dir".to_string(), + "lla plugin --name file_organizer --action organize /path/to/dir extension" + .to_string(), + "lla plugin --name file_organizer --action organize /path/to/dir date" + .to_string(), + "lla plugin --name file_organizer --action organize /path/to/dir type" + .to_string(), + "lla plugin --name file_organizer --action organize /path/to/dir size" + .to_string(), + ], + ) + .add_command( + "preview".to_string(), + "Preview organization changes".to_string(), + vec![ + "lla plugin --name file_organizer --action preview /path/to/dir".to_string(), + "lla plugin --name file_organizer --action preview /path/to/dir extension" + .to_string(), + "lla plugin --name file_organizer --action preview /path/to/dir date" + .to_string(), + "lla plugin --name file_organizer --action preview /path/to/dir type" + .to_string(), + "lla plugin --name file_organizer --action preview /path/to/dir size" + .to_string(), + ], + ); + + println!( + "{}", + BoxComponent::new(help.render(&OrganizerConfig::default().colors)) + .style(BoxStyle::Minimal) + .padding(1) + .render() + ); + Ok(()) + } +} + +impl Default for FileOrganizerPlugin { + fn default() -> Self { + Self::new() + } +} + +impl Deref for FileOrganizerPlugin { + type Target = OrganizerConfig; + + fn deref(&self) -> &Self::Target { + self.base.config() + } +} + +impl Plugin for FileOrganizerPlugin { + fn handle_raw_request(&mut self, request: &[u8]) -> Vec { + match self.decode_request(request) { + Ok(request) => { + let response = match request { + PluginRequest::GetName => { + PluginResponse::Name(env!("CARGO_PKG_NAME").to_string()) + } + PluginRequest::GetVersion => { + PluginResponse::Version(env!("CARGO_PKG_VERSION").to_string()) + } + PluginRequest::GetDescription => { + PluginResponse::Description(env!("CARGO_PKG_DESCRIPTION").to_string()) + } + PluginRequest::GetSupportedFormats => { + PluginResponse::SupportedFormats(vec!["default".to_string()]) + } + PluginRequest::Decorate(entry) => PluginResponse::Decorated(entry), + PluginRequest::FormatField(_, _) => PluginResponse::FormattedField(None), + PluginRequest::PerformAction(action, args) => { + let result = ACTION_REGISTRY.read().handle(&action, &args); + PluginResponse::ActionResult(result) + } + }; + self.encode_response(response) + } + Err(e) => self.encode_error(&e), + } + } +} + +impl ConfigurablePlugin for FileOrganizerPlugin { + type Config = OrganizerConfig; + + fn config(&self) -> &Self::Config { + self.base.config() + } + + fn config_mut(&mut self) -> &mut Self::Config { + self.base.config_mut() + } +} + +impl ProtobufHandler for FileOrganizerPlugin {} + +lla_plugin_interface::declare_plugin!(FileOrganizerPlugin); diff --git a/plugins/file_organizer/src/strategies/date.rs b/plugins/file_organizer/src/strategies/date.rs new file mode 100644 index 0000000..75ad3fd --- /dev/null +++ b/plugins/file_organizer/src/strategies/date.rs @@ -0,0 +1,90 @@ +use super::OrganizationStrategy; +use crate::config::DateStrategyConfig; +use chrono::{DateTime, Local, TimeZone}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +pub struct DateStrategy { + config: DateStrategyConfig, +} + +impl DateStrategy { + pub fn new(config: DateStrategyConfig) -> Self { + Self { config } + } + + fn get_date_from_metadata(&self, path: &Path) -> Option> { + path.metadata() + .ok()? + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| Local.timestamp_opt(d.as_secs() as i64, 0).single())? + } + + fn format_date_path(&self, date: DateTime) -> PathBuf { + let path_str = match self.config.group_by.as_str() { + "year" => date.format("%Y").to_string(), + "day" => date.format("%Y/%m/%d").to_string(), + _ => date.format("%Y/%m").to_string(), + }; + PathBuf::from(path_str) + } +} + +impl OrganizationStrategy for DateStrategy { + fn organize(&self, dir: &Path, dry_run: bool) -> Result, String> { + if !self.config.enabled { + return Ok(Vec::new()); + } + + let mut moves = Vec::new(); + let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {}", e))?; + + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if path.is_dir() { + continue; + } + + if let Some(date) = self.get_date_from_metadata(&path) { + let relative_date_path = self.format_date_path(date); + let target_dir = dir.join(&relative_date_path); + let target_path = target_dir.join(path.file_name().unwrap()); + + if !target_dir.exists() && !dry_run { + fs::create_dir_all(&target_dir) + .map_err(|e| format!("Failed to create directory: {}", e))?; + } + + moves.push((path, target_path)); + } + } + + Ok(moves) + } + + fn execute_moves(&self, moves: Vec<(PathBuf, PathBuf)>) -> Result<(), String> { + for (source, target) in moves { + if let Some(parent) = target.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory: {}", e))?; + } + } + + fs::rename(&source, &target).map_err(|e| { + format!( + "Failed to move '{}' to '{}': {}", + source.display(), + target.display(), + e + ) + })?; + } + Ok(()) + } +} diff --git a/plugins/file_organizer/src/strategies/extension.rs b/plugins/file_organizer/src/strategies/extension.rs new file mode 100644 index 0000000..bd490eb --- /dev/null +++ b/plugins/file_organizer/src/strategies/extension.rs @@ -0,0 +1,99 @@ +use super::OrganizationStrategy; +use crate::config::ExtensionStrategyConfig; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +pub struct ExtensionStrategy { + config: ExtensionStrategyConfig, +} + +impl ExtensionStrategy { + pub fn new(config: ExtensionStrategyConfig) -> Self { + Self { config } + } +} + +impl OrganizationStrategy for ExtensionStrategy { + fn organize(&self, dir: &Path, dry_run: bool) -> Result, String> { + if !self.config.enabled { + return Ok(Vec::new()); + } + + let mut moves = Vec::new(); + let mut extension_dirs: HashMap = HashMap::new(); + + let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {}", e))?; + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if path.is_dir() { + continue; + } + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + let ext = ext.to_lowercase(); + let target_dir = extension_dirs.entry(ext.clone()).or_insert_with(|| { + let mut target = PathBuf::from(dir); + if self.config.create_nested { + match ext.as_str() { + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" => { + target.push("images"); + target.push(ext); + } + "mp4" | "avi" | "mov" | "mkv" | "wmv" => { + target.push("videos"); + target.push(ext); + } + "mp3" | "wav" | "flac" | "m4a" | "ogg" => { + target.push("audio"); + target.push(ext); + } + "doc" | "docx" | "pdf" | "txt" | "rtf" | "md" => { + target.push("documents"); + target.push(ext); + } + "zip" | "rar" | "7z" | "tar" | "gz" => { + target.push("archives"); + target.push(ext); + } + _ => target.push(ext), + } + } else { + target.push(ext); + } + target + }); + if !target_dir.exists() && !dry_run { + fs::create_dir_all(target_dir.clone()) + .map_err(|e| format!("Failed to create directory: {}", e))?; + } + let target_path = target_dir.join(path.file_name().unwrap()); + moves.push((path, target_path)); + } + } + + Ok(moves) + } + + fn execute_moves(&self, moves: Vec<(PathBuf, PathBuf)>) -> Result<(), String> { + for (source, target) in moves { + if let Some(parent) = target.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory: {}", e))?; + } + } + + fs::rename(&source, &target).map_err(|e| { + format!( + "Failed to move '{}' to '{}': {}", + source.display(), + target.display(), + e + ) + })?; + } + Ok(()) + } +} diff --git a/plugins/file_organizer/src/strategies/mod.rs b/plugins/file_organizer/src/strategies/mod.rs new file mode 100644 index 0000000..737a13a --- /dev/null +++ b/plugins/file_organizer/src/strategies/mod.rs @@ -0,0 +1,16 @@ +pub mod date; +pub mod extension; +pub mod size; +pub mod r#type; + +use std::path::{Path, PathBuf}; + +pub trait OrganizationStrategy { + fn organize(&self, dir: &Path, dry_run: bool) -> Result, String>; + fn execute_moves(&self, moves: Vec<(PathBuf, PathBuf)>) -> Result<(), String>; +} + +pub use date::DateStrategy; +pub use extension::ExtensionStrategy; +pub use r#type::TypeStrategy; +pub use size::SizeStrategy; diff --git a/plugins/file_organizer/src/strategies/size.rs b/plugins/file_organizer/src/strategies/size.rs new file mode 100644 index 0000000..8261172 --- /dev/null +++ b/plugins/file_organizer/src/strategies/size.rs @@ -0,0 +1,83 @@ +use super::OrganizationStrategy; +use crate::config::SizeStrategyConfig; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +pub struct SizeStrategy { + config: SizeStrategyConfig, +} + +impl SizeStrategy { + pub fn new(config: SizeStrategyConfig) -> Self { + Self { config } + } + + fn get_size_category(&self, size: u64) -> Option { + self.config + .ranges + .iter() + .find(|range| match range.max_bytes { + Some(max) => size <= max, + None => true, + }) + .map(|range| range.name.clone()) + } +} + +impl OrganizationStrategy for SizeStrategy { + fn organize(&self, dir: &Path, dry_run: bool) -> Result, String> { + if !self.config.enabled { + return Ok(Vec::new()); + } + + let mut moves = Vec::new(); + let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {}", e))?; + + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if path.is_dir() { + continue; + } + + let metadata = fs::metadata(&path) + .map_err(|e| format!("Failed to get metadata for '{}': {}", path.display(), e))?; + + if let Some(category) = self.get_size_category(metadata.len()) { + let target_dir = dir.join(&category); + let target_path = target_dir.join(path.file_name().unwrap()); + + if !target_dir.exists() && !dry_run { + fs::create_dir_all(&target_dir) + .map_err(|e| format!("Failed to create directory: {}", e))?; + } + + moves.push((path, target_path)); + } + } + + Ok(moves) + } + + fn execute_moves(&self, moves: Vec<(PathBuf, PathBuf)>) -> Result<(), String> { + for (source, target) in moves { + if let Some(parent) = target.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory: {}", e))?; + } + } + + fs::rename(&source, &target).map_err(|e| { + format!( + "Failed to move '{}' to '{}': {}", + source.display(), + target.display(), + e + ) + })?; + } + Ok(()) + } +} diff --git a/plugins/file_organizer/src/strategies/type.rs b/plugins/file_organizer/src/strategies/type.rs new file mode 100644 index 0000000..153868f --- /dev/null +++ b/plugins/file_organizer/src/strategies/type.rs @@ -0,0 +1,88 @@ +use super::OrganizationStrategy; +use crate::config::TypeStrategyConfig; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +pub struct TypeStrategy { + config: TypeStrategyConfig, + extension_to_category: HashMap, +} + +impl TypeStrategy { + pub fn new(config: TypeStrategyConfig) -> Self { + let mut extension_to_category = HashMap::new(); + for (category, extensions) in &config.categories { + for ext in extensions { + extension_to_category.insert(ext.clone(), category.clone()); + } + } + Self { + config, + extension_to_category, + } + } + + fn get_category(&self, path: &Path) -> Option { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_lowercase()) + .and_then(|ext| self.extension_to_category.get(&ext)) + .cloned() + } +} + +impl OrganizationStrategy for TypeStrategy { + fn organize(&self, dir: &Path, dry_run: bool) -> Result, String> { + if !self.config.enabled { + return Ok(Vec::new()); + } + + let mut moves = Vec::new(); + let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {}", e))?; + + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if path.is_dir() { + continue; + } + + if let Some(category) = self.get_category(&path) { + let target_dir = dir.join(&category); + let target_path = target_dir.join(path.file_name().unwrap()); + + if !target_dir.exists() && !dry_run { + fs::create_dir_all(&target_dir) + .map_err(|e| format!("Failed to create directory: {}", e))?; + } + + moves.push((path, target_path)); + } + } + + Ok(moves) + } + + fn execute_moves(&self, moves: Vec<(PathBuf, PathBuf)>) -> Result<(), String> { + for (source, target) in moves { + if let Some(parent) = target.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory: {}", e))?; + } + } + + fs::rename(&source, &target).map_err(|e| { + format!( + "Failed to move '{}' to '{}': {}", + source.display(), + target.display(), + e + ) + })?; + } + Ok(()) + } +} diff --git a/plugins/file_remover/Cargo.toml b/plugins/file_remover/Cargo.toml new file mode 100644 index 0000000..0119aba --- /dev/null +++ b/plugins/file_remover/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "file_remover" +version = "0.1.0" +edition = "2021" +description = "A plugin for lla that provides interactive file and directory removal" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +lla_plugin_interface = { path = "../../lla_plugin_interface" } +lla_plugin_utils = { path = "../../lla_plugin_utils" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +dialoguer = "0.11" +colored = "2.0" +lazy_static = "1.4" +parking_lot = "0.12" diff --git a/plugins/file_remover/README.md b/plugins/file_remover/README.md new file mode 100644 index 0000000..c0060f4 --- /dev/null +++ b/plugins/file_remover/README.md @@ -0,0 +1,70 @@ +# lla File Remover Plugin + +A plugin for `lla` that provides an interactive interface for safely removing files and directories. + +## Features + +- **Interactive Selection**: Multi-select interface for choosing files to remove +- **Path Flexibility**: Support for both current and specified directories +- **Safe Operations**: Confirmation prompts and error handling for safe removal +- **Directory Support**: Recursive removal of directories +- **User Interface**: Colored output and interactive menus + +## Configuration + +```toml +[colors] +success = "bright_green" +info = "bright_blue" +error = "bright_red" +path = "bright_yellow" +``` + +## Usage + +### Basic Operations + +```bash +# Remove files/directories from current directory +lla plugin --name file_remover --action remove + +# Remove files/directories from specified directory +lla plugin --name file_remover --action remove /path/to/directory + +# Show help information +lla plugin --name file_remover --action help +``` + +## Common Workflows + +### 1. Removing Files from Current Directory + +```bash +# In target directory +cd /path/to/directory +lla plugin --name file_remover --action remove +# Select files to remove using space, confirm with enter +``` + +### 2. Removing Files from Specific Directory + +```bash +# Remove files from a specific directory without changing location +lla plugin --name file_remover --action remove /path/to/directory +# Select files to remove using space, confirm with enter +``` + +## Display Format + +``` +───────────────────────────────────── + File Remover +───────────────────────────────────── + Select items to remove: + → file1.txt + → directory1 + → file2.rs +───────────────────────────────────── + Use Space to select, Enter to confirm +───────────────────────────────────── +``` diff --git a/plugins/file_remover/src/lib.rs b/plugins/file_remover/src/lib.rs new file mode 100644 index 0000000..b759ca5 --- /dev/null +++ b/plugins/file_remover/src/lib.rs @@ -0,0 +1,298 @@ +use colored::Colorize; +use dialoguer::{Confirm, MultiSelect}; +use lazy_static::lazy_static; +use lla_plugin_interface::{Plugin, PluginRequest, PluginResponse}; +use lla_plugin_utils::{ + config::PluginConfig, + ui::components::{BoxComponent, BoxStyle, HelpFormatter, LlaDialoguerTheme}, + ActionRegistry, BasePlugin, ConfigurablePlugin, ProtobufHandler, +}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + ops::Deref, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoverConfig { + #[serde(default = "default_colors")] + colors: std::collections::HashMap, +} + +fn default_colors() -> std::collections::HashMap { + let mut colors = std::collections::HashMap::new(); + colors.insert("success".to_string(), "bright_green".to_string()); + colors.insert("info".to_string(), "bright_blue".to_string()); + colors.insert("error".to_string(), "bright_red".to_string()); + colors.insert("path".to_string(), "bright_yellow".to_string()); + colors +} + +impl Default for RemoverConfig { + fn default() -> Self { + Self { + colors: default_colors(), + } + } +} + +impl PluginConfig for RemoverConfig {} + +lazy_static! { + static ref ACTION_REGISTRY: RwLock = RwLock::new({ + let mut registry = ActionRegistry::new(); + + lla_plugin_utils::define_action!( + registry, + "remove", + "remove [path]", + "Remove files/directories from current or specified directory", + vec![ + "lla plugin --name file_remover --action remove", + "lla plugin --name file_remover --action remove /path/to/dir" + ], + |args| FileRemoverPlugin::remove_action(args) + ); + + lla_plugin_utils::define_action!( + registry, + "help", + "help", + "Show help information", + vec!["lla plugin --name file_remover --action help"], + |_| FileRemoverPlugin::help_action() + ); + + registry + }); +} + +pub struct FileRemoverPlugin { + base: BasePlugin, +} + +impl FileRemoverPlugin { + pub fn new() -> Self { + let plugin_name = env!("CARGO_PKG_NAME"); + Self { + base: BasePlugin::with_name(plugin_name), + } + } + + fn get_directory(path_arg: Option<&str>) -> Result { + match path_arg { + Some(path) => Ok(PathBuf::from(path)), + None => std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e)), + } + } + + fn remove_directory_recursively(path: &Path) -> Result<(), String> { + if !path.is_dir() { + return Err(format!("{} is not a directory", path.display())); + } + + fs::remove_dir_all(path) + .map_err(|e| format!("Failed to remove directory {}: {}", path.display(), e)) + } + + fn remove_item(path: &Path) -> Result<(), String> { + if path.is_dir() { + Self::remove_directory_recursively(path) + } else { + fs::remove_file(path) + .map_err(|e| format!("Failed to remove file {}: {}", path.display(), e)) + } + } + + fn remove_action(args: &[String]) -> Result<(), String> { + let dir = Self::get_directory(args.get(0).map(|s| s.as_str()))?; + + let entries = fs::read_dir(&dir) + .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))? + .filter_map(Result::ok) + .map(|e| e.path()) + .collect::>(); + + if entries.is_empty() { + println!( + "{} Directory is empty: {}", + "Info:".bright_blue(), + dir.display() + ); + return Ok(()); + } + + let items: Vec = entries + .iter() + .map(|p| { + let name = p + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + if p.is_dir() { + format!("{} (directory)", name) + } else { + name + } + }) + .collect(); + + let theme = LlaDialoguerTheme::default(); + let selections = MultiSelect::with_theme(&theme) + .with_prompt("Select items to remove (Space to select, Enter to confirm)") + .items(&items) + .interact() + .map_err(|e| format!("Failed to show selector: {}", e))?; + + if selections.is_empty() { + println!("{} No items selected", "Info:".bright_blue()); + return Ok(()); + } + + println!( + "\n{} The following items will be removed:", + "Warning:".bright_yellow() + ); + for &idx in &selections { + println!(" {} {}", "→".bright_red(), items[idx].bright_yellow()); + } + + let confirmed = Confirm::with_theme(&theme) + .with_prompt("Are you sure you want to remove these items?") + .default(false) + .interact() + .map_err(|e| format!("Failed to show confirmation: {}", e))?; + + if !confirmed { + println!("{} Operation cancelled", "Info:".bright_blue()); + return Ok(()); + } + + let mut success_count = 0; + let mut error_count = 0; + + for &idx in &selections { + let path = &entries[idx]; + match Self::remove_item(path) { + Ok(()) => { + println!( + "{} Removed: {}", + "Success:".bright_green(), + path.display().to_string().bright_yellow() + ); + success_count += 1; + } + Err(e) => { + println!( + "{} Failed to remove {}: {}", + "Error:".bright_red(), + path.display().to_string().bright_yellow(), + e + ); + error_count += 1; + } + } + } + + println!( + "\n{} Operation completed: {} items removed, {} errors", + "Summary:".bright_blue(), + success_count.to_string().bright_green(), + error_count.to_string().bright_red() + ); + + Ok(()) + } + + fn help_action() -> Result<(), String> { + let mut help = HelpFormatter::new("File Remover".to_string()); + help.add_section("Description".to_string()).add_command( + "".to_string(), + "Remove files and directories with interactive selection".to_string(), + vec![], + ); + + help.add_section("Commands".to_string()).add_command( + "remove [path]".to_string(), + "Remove files/directories from current or specified directory".to_string(), + vec![ + "lla plugin --name file_remover --action remove".to_string(), + "lla plugin --name file_remover --action remove /path/to/dir".to_string(), + ], + ); + + println!( + "{}", + BoxComponent::new(help.render(&RemoverConfig::default().colors)) + .style(BoxStyle::Minimal) + .padding(1) + .render() + ); + Ok(()) + } +} + +impl Deref for FileRemoverPlugin { + type Target = RemoverConfig; + + fn deref(&self) -> &Self::Target { + self.base.config() + } +} + +impl Plugin for FileRemoverPlugin { + fn handle_raw_request(&mut self, request: &[u8]) -> Vec { + match self.decode_request(request) { + Ok(request) => { + let response = match request { + PluginRequest::GetName => { + PluginResponse::Name(env!("CARGO_PKG_NAME").to_string()) + } + PluginRequest::GetVersion => { + PluginResponse::Version(env!("CARGO_PKG_VERSION").to_string()) + } + PluginRequest::GetDescription => { + PluginResponse::Description(env!("CARGO_PKG_DESCRIPTION").to_string()) + } + PluginRequest::GetSupportedFormats => { + PluginResponse::SupportedFormats(vec!["default".to_string()]) + } + PluginRequest::Decorate(entry) => PluginResponse::Decorated(entry), + PluginRequest::FormatField(_, _) => PluginResponse::FormattedField(None), + PluginRequest::PerformAction(action, args) => { + let result = ACTION_REGISTRY.read().handle(&action, &args); + PluginResponse::ActionResult(result) + } + }; + self.encode_response(response) + } + Err(e) => self.encode_error(&e), + } + } +} + +impl ConfigurablePlugin for FileRemoverPlugin { + type Config = RemoverConfig; + + fn config(&self) -> &Self::Config { + self.base.config() + } + + fn config_mut(&mut self) -> &mut Self::Config { + self.base.config_mut() + } +} + +impl ProtobufHandler for FileRemoverPlugin {} + +lla_plugin_interface::declare_plugin!(FileRemoverPlugin); + +impl Default for FileRemoverPlugin { + fn default() -> Self { + Self::new() + } +} diff --git a/plugins/file_tagger/README.md b/plugins/file_tagger/README.md index bf8d8a5..68fec59 100644 --- a/plugins/file_tagger/README.md +++ b/plugins/file_tagger/README.md @@ -1,4 +1,4 @@ -# LLA File Tagger Plugin +# lla File Tagger Plugin A file tagging plugin for `lla` that provides persistent tag management. diff --git a/plugins/git_status/README.md b/plugins/git_status/README.md index 49d2acc..3734a52 100644 --- a/plugins/git_status/README.md +++ b/plugins/git_status/README.md @@ -1,4 +1,4 @@ -# LLA Git Status Plugin +# lla Git Status Plugin Git integration plugin for `lla` providing real-time repository status with rich formatting. diff --git a/plugins/keyword_search/README.md b/plugins/keyword_search/README.md index 12751c0..3df9b8b 100644 --- a/plugins/keyword_search/README.md +++ b/plugins/keyword_search/README.md @@ -1,4 +1,4 @@ -# LLA Keyword Search Plugin +# lla Keyword Search Plugin High-performance keyword search plugin for `lla` with interactive search and rich display features. diff --git a/plugins/keyword_search/src/lib.rs b/plugins/keyword_search/src/lib.rs index 6aebfc8..e28b5b8 100644 --- a/plugins/keyword_search/src/lib.rs +++ b/plugins/keyword_search/src/lib.rs @@ -1,12 +1,12 @@ use arboard::Clipboard; use colored::Colorize; -use dialoguer::{theme::ColorfulTheme, MultiSelect, Select}; +use dialoguer::{MultiSelect, Select}; use itertools::Itertools; use lazy_static::lazy_static; use lla_plugin_interface::{Plugin, PluginRequest, PluginResponse}; use lla_plugin_utils::{ config::PluginConfig, - ui::components::{BoxComponent, BoxStyle, HelpFormatter}, + ui::components::{BoxComponent, BoxStyle, HelpFormatter, LlaDialoguerTheme}, BasePlugin, ConfigurablePlugin, ProtobufHandler, }; use regex::RegexBuilder; @@ -290,7 +290,8 @@ impl KeywordSearchPlugin { }) .collect(); - let selection = MultiSelect::with_theme(&ColorfulTheme::default()) + let theme = LlaDialoguerTheme::default(); + let selection = MultiSelect::with_theme(&theme) .with_prompt(format!("{} Select matches to process", "🔍".bright_cyan())) .items(&items) .defaults(&vec![true; items.len()]) @@ -313,7 +314,8 @@ impl KeywordSearchPlugin { "📈 Advanced analysis", ]; - let action_selection = Select::with_theme(&ColorfulTheme::default()) + let theme = LlaDialoguerTheme::default(); + let action_selection = Select::with_theme(&theme) .with_prompt(format!("{} Choose action", "⚡".bright_cyan())) .items(&actions) .default(0) @@ -386,7 +388,8 @@ impl KeywordSearchPlugin { "keyword_matches_{}.txt", chrono::Local::now().format("%Y%m%d_%H%M%S") ); - let input = dialoguer::Input::::with_theme(&ColorfulTheme::default()) + let theme = LlaDialoguerTheme::default(); + let input = dialoguer::Input::::with_theme(&theme) .with_prompt("Enter file path to save") .with_initial_text(&default_filename) .interact_text() @@ -474,7 +477,8 @@ impl KeywordSearchPlugin { .into_iter() .collect(); - let keyword_selection = MultiSelect::with_theme(&ColorfulTheme::default()) + let theme = LlaDialoguerTheme::default(); + let keyword_selection = MultiSelect::with_theme(&theme) .with_prompt("Filter by keywords") .items(&keywords) .interact() @@ -594,12 +598,13 @@ impl Plugin for KeywordSearchPlugin { let result = (|| { let config = self.base.config(); if config.keywords.is_empty() { - let input = dialoguer::Input::::with_theme( - &ColorfulTheme::default(), - ) - .with_prompt("Enter keywords (space-separated)") - .interact_text() - .map_err(|e| format!("Failed to get keywords: {}", e))?; + let theme = LlaDialoguerTheme::default(); + let input = dialoguer::Input::::with_theme(&theme) + .with_prompt("Enter keywords (space-separated)") + .interact_text() + .map_err(|e| { + format!("Failed to get keywords: {}", e) + })?; let keywords: Vec = input .split_whitespace() @@ -641,14 +646,16 @@ impl Plugin for KeywordSearchPlugin { ); } - let selection = - MultiSelect::with_theme(&ColorfulTheme::default()) - .with_prompt("Select files to search") - .items(&files) - .interact() - .map_err(|e| { - format!("Failed to show file selector: {}", e) - })?; + let files = + files.iter().map(|p| p.to_string()).collect::>(); + let theme = LlaDialoguerTheme::default(); + let selection = MultiSelect::with_theme(&theme) + .with_prompt("Select files to search") + .items(&files) + .interact() + .map_err(|e| { + format!("Failed to show file selector: {}", e) + })?; if selection.is_empty() { return Err("No files selected".to_string()); diff --git a/plugins/last_git_commit/Cargo.toml b/plugins/last_git_commit/Cargo.toml index 3cd3918..abd67c8 100644 --- a/plugins/last_git_commit/Cargo.toml +++ b/plugins/last_git_commit/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "last_git_commit" -description = "A plugin for the LLA that provides the last git commit hash" +description = "A plugin for the lla that provides the last git commit hash" version = "0.3.1" edition = "2021" diff --git a/plugins/last_git_commit/README.md b/plugins/last_git_commit/README.md index 216abf8..5942f98 100644 --- a/plugins/last_git_commit/README.md +++ b/plugins/last_git_commit/README.md @@ -1,4 +1,4 @@ -# LLA Last Git Commit Plugin +# lla Last Git Commit Plugin Git history plugin for `lla` providing real-time commit tracking with rich formatting. diff --git a/plugins/sizeviz/Cargo.toml b/plugins/sizeviz/Cargo.toml index 3d1afba..7b0e842 100644 --- a/plugins/sizeviz/Cargo.toml +++ b/plugins/sizeviz/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sizeviz" -description = "File size visualizer plugin for LLA" +description = "File size visualizer plugin for lla" version = "0.3.1" edition = "2021" diff --git a/plugins/sizeviz/README.md b/plugins/sizeviz/README.md index d5e8d35..0c3a4f3 100644 --- a/plugins/sizeviz/README.md +++ b/plugins/sizeviz/README.md @@ -1,4 +1,4 @@ -# LLA Size Visualizer Plugin +# lla Size Visualizer Plugin File size visualization plugin for `lla` providing real-time size analysis with rich formatting. diff --git a/themes/README.md b/themes/README.md index 07f8b55..396674c 100644 --- a/themes/README.md +++ b/themes/README.md @@ -1,6 +1,20 @@ -# LLA Themes +# lla Themes -LLA provides a powerful and flexible theming system that allows you to customize the appearance of your file listings. Each theme is defined in a TOML file and can customize colors for files, folders, permissions, and more. +lla provides a powerful and flexible theming system that allows you to customize the appearance of your file listings. Each theme is defined in a TOML file and can customize colors for files, folders, permissions, and more. + +lla ships with a default theme collection, you can pull all builtin themes with the following command: + +```bash +lla theme pull # pull all builtin themes +``` + +you can also install custom themes using the following command: + +```bash +lla theme install /path/to/theme.toml +# or a directory with multiple themes +lla theme install /path/to/themes/ +``` ## Table of Contents @@ -52,7 +66,7 @@ directory = "#89AFFF" ## Color Formats -LLA supports multiple color formats for maximum flexibility: +lla supports multiple color formats for maximum flexibility: ```toml [colors] @@ -192,7 +206,7 @@ rs = "#FF0000" # Override just .rs files ## Color Resolution Order -When determining a file's color, LLA follows this priority: +When determining a file's color, lla follows this priority: 1. If it's a directory: @@ -210,7 +224,7 @@ When determining a file's color, LLA follows this priority: ## Built-in Themes -LLA includes several pre-configured themes: +lla includes several pre-configured themes: - **default**: Traditional terminal colors with carefully selected colors for optimal visibility - **dark**: Modern dark theme with enhanced visibility and rich colors for dark terminals