diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b20ce4..ee413da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,27 +7,45 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: macos-latest steps: - - uses: actions/checkout@v3 - - name: Install Composer dependencies - uses: php-actions/composer@v6 + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + # Store version number without `v` - name: Write release version run: | TAG=${{ github.ref_name }} echo "VERSION=${TAG#v}" >> $GITHUB_ENV + - name: Build Alfred workflow - id: alfred_builder - uses: com30n/build-alfred-workflow@v1 - with: - workflow_dir: . - exclude_patterns: ".git/* .gitignore .github/* docker_tag Dockerfile-php-build DOCKER_ENV output.log resources/*" - custom_version: "${{ env.VERSION }}" + run: make package-universal + - name: Create release id: create_release uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: - token: ${{ secrets.RELEASE_TOKEN }} - files: ${{ steps.alfred_builder.outputs.workflow_file }} + token: ${{ secrets.GITHUB_TOKEN }} + files: quick-open-project.alfredworkflow + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + body: | + ## Quick Open Project Alfred Workflow ${{ github.ref_name }} + + Download the `.alfredworkflow` file below and double-click to install. + + This release includes: + - Rust-based implementation for optimal performance + - ARM64 and x86_64 macOS compatibility + - Fuzzy search functionality + + ### Installation + 1. Download `quick-open-project.alfredworkflow` + 2. Double-click the file to install in Alfred + 3. Configure your `SEARCH_PATHS` environment variable in Alfred's workflow settings + + ### Usage + Use the `src` keyword in Alfred to search your project directories. diff --git a/.gitignore b/.gitignore index 0f463a1..5ab07c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,21 @@ +# IDE files .idea +.vscode/ + +# macOS files .DS_Store -vendor/ \ No newline at end of file + +# Rust build artifacts +target/ + +# Build outputs +search +*.alfredworkflow +info.plist.tmp +update_plist.py +fix_workflow_zip.py + +# Debug and temporary files +*.log +.env +.env.local diff --git a/CHANGELOG.md b/CHANGELOG.md index 3185c8a..076ae8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Rewrote in Rust. + ## [0.0.5] - 2025-04-04 ### Fixed diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6022ef3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,375 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "proc-macro2" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "search" +version = "0.1.0" +dependencies = [ + "nucleo-matcher", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.142" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..df77720 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "search" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "search" +path = "src/main.rs" + +[dependencies] +nucleo-matcher = "0.3.1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[dev-dependencies] +tempfile = "3.0" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +panic = "abort" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..10ed169 --- /dev/null +++ b/Makefile @@ -0,0 +1,79 @@ +.PHONY: build clean install workflow package test help + +# Default target +all: build + +# Build the Rust binary (local development) +build: + cargo build --release + cp target/release/search ./search + +# Build universal binary for both Intel and Apple Silicon (requires rustup) +build-universal: + rustup target add aarch64-apple-darwin || true + rustup target add x86_64-apple-darwin || true + cargo build --release --target aarch64-apple-darwin + cargo build --release --target x86_64-apple-darwin + lipo -create -output search \ + target/aarch64-apple-darwin/release/search \ + target/x86_64-apple-darwin/release/search + +# Clean build artifacts +clean: + cargo clean + rm -f search + rm -f *.alfredworkflow + +# Run tests +test: + cargo test + +# Install dependencies (for CI) +install: + # Rust dependencies are handled by Cargo + # Add targets for universal binary support + rustup target add aarch64-apple-darwin + rustup target add x86_64-apple-darwin + +# Create Alfred workflow package +workflow: build prepare-plist + @echo "Creating Alfred workflow package..." + zip -r quick-open-project.alfredworkflow \ + search \ + info.plist.tmp \ + icon.png \ + warning.png \ + README.md \ + LICENSE.md \ + CHANGELOG.md + # Rename to correct info.plist in the zip + @python3 fix_workflow_zip.py + rm -f info.plist.tmp + @echo "✅ Created quick-open-project.alfredworkflow" + +# Prepare info.plist with version +prepare-plist: + $(eval VERSION := $(shell grep '^version' Cargo.toml | cut -d'"' -f2)) + @echo "Setting workflow version to $(VERSION)" + # Create updated plist + @python3 update_plist.py $(VERSION) + +# Create workflow package for release (current architecture) +package: clean build workflow + @echo "🎉 Package ready for release!" + +# Create universal workflow package for release (both Intel and Apple Silicon) +package-universal: clean build-universal workflow + @echo "🎉 Universal package ready for release!" + +# Show help +help: + @echo "Available targets:" + @echo " build - Build the Rust binary (current architecture)" + @echo " build-universal - Build universal binary (Intel + Apple Silicon)" + @echo " test - Run all tests" + @echo " clean - Clean build artifacts" + @echo " workflow - Create .alfredworkflow package (auto-sets version)" + @echo " package - Clean build and create workflow package" + @echo " package-universal - Clean build universal and create workflow package" + @echo " help - Show this help message" \ No newline at end of file diff --git a/README.md b/README.md index caec5a9..5a1d48d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This Alfred workflow fuzzy-searches your development directories so you can quickly open projects in VS Code, PhpStorm, iTerm, or Finder. -It’s heavily tailored to how I use it, but you can change the editors quickly and even dig into `search.php` if you’d like to adjust the configuration of the underlying [Fuze](https://github.com/Loilo/Fuse) library. +The workflow is implemented in Rust for optimal performance and cross-platform compatibility on macOS. ![Workflow screenshot](resources/screenshot.png) diff --git a/composer.json b/composer.json deleted file mode 100644 index 4d8642c..0000000 --- a/composer.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "mattstein/alfred-quick-open-project-workflow", - "type": "project", - "description": "Quickly open project folders in your text editor or terminal.", - "keywords": [ - "alfred" - ], - "license": "MIT", - "require": { - "php": "^8.0", - "joetannenbaum/alfred-workflow": "dev-master", - "loilo/fuse": "^7.0" - }, - "minimum-stability": "stable", - "prefer-stable": true, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/mattstein/alfred-workflow" - } - ] -} diff --git a/composer.lock b/composer.lock deleted file mode 100644 index 2cf4b70..0000000 --- a/composer.lock +++ /dev/null @@ -1,132 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "4e9e552ceebc52bd05cde34c72d11458", - "packages": [ - { - "name": "joetannenbaum/alfred-workflow", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/mattstein/alfred-workflow.git", - "reference": "a8fe2083228eed6a86b54c025e2c8b2c6b7aedca" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/mattstein/alfred-workflow/zipball/a8fe2083228eed6a86b54c025e2c8b2c6b7aedca", - "reference": "a8fe2083228eed6a86b54c025e2c8b2c6b7aedca", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": "^7.4.0||^8.0" - }, - "require-dev": { - "laravel/pint": "^1.1", - "pestphp/pest": "^1.21", - "phpunit/phpunit": "^9.5" - }, - "default-branch": true, - "type": "library", - "autoload": { - "psr-4": { - "Alfred\\Workflows\\": "src/" - } - }, - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Joe Tannenbaum", - "email": "joe@joe.codes" - } - ], - "description": "PHP helper for creating Alfred Workflows.", - "homepage": "https://joe.codes", - "support": { - "source": "https://github.com/joetannenbaum/alfred-workflow", - "issues": "https://github.com/joetannenbaum/alfred-workflow/issues" - }, - "time": "2024-09-26T18:05:26+00:00" - }, - { - "name": "loilo/fuse", - "version": "7.1.1", - "source": { - "type": "git", - "url": "https://github.com/loilo/Fuse.git", - "reference": "aef0aecc4f7eada734ecc44cb403f453d8fedca2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/loilo/Fuse/zipball/aef0aecc4f7eada734ecc44cb403f453d8fedca2", - "reference": "aef0aecc4f7eada734ecc44cb403f453d8fedca2", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "pestphp/pest": "^3.0", - "phpstan/phpstan": "^2.1", - "squizlabs/php_codesniffer": "^3.6" - }, - "type": "library", - "autoload": { - "files": [ - "src/Core/computeScore.php", - "src/Core/config.php", - "src/Core/format.php", - "src/Core/parse.php", - "src/Helpers/diacritics.php", - "src/Helpers/get.php", - "src/Helpers/sort.php", - "src/Helpers/types.php", - "src/Search/Bitap/computeScore.php", - "src/Search/Bitap/convertMaskToIndices.php", - "src/Search/Bitap/createPatternAlphabet.php", - "src/Search/Bitap/search.php", - "src/Search/Extended/parseQuery.php", - "src/Transform/transformMatches.php", - "src/Transform/transformScore.php" - ], - "psr-4": { - "Fuse\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Florian Reuschel", - "email": "florian@loilo.de" - } - ], - "description": "Fuzzy search for PHP based on Bitap algorithm", - "support": { - "issues": "https://github.com/loilo/Fuse/issues", - "source": "https://github.com/loilo/Fuse/tree/v7.1.1" - }, - "time": "2025-02-04T13:21:33+00:00" - } - ], - "packages-dev": [], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": { - "joetannenbaum/alfred-workflow": 20 - }, - "prefer-stable": true, - "prefer-lowest": false, - "platform": { - "php": "^8.0" - }, - "platform-dev": {}, - "plugin-api-version": "2.6.0" -} diff --git a/info.plist b/info.plist index 7f7dddc..1f03065 100644 --- a/info.plist +++ b/info.plist @@ -114,7 +114,7 @@ escaping 127 keyword - src + {var:keyword} queuedelaycustom 1 queuedelayimmediatelyinitially @@ -126,7 +126,7 @@ runningsubtext Hang on... script - php search.php {query} + ./search {query} scriptargtype 0 scriptfile @@ -228,7 +228,26 @@ end CommandRun readme - + # Quick Open Project + +This Alfred workflow fuzzy-searches your development directories so you can quickly open projects in VS Code, PhpStorm, iTerm, or Finder. + +## Configuration + +You need to configure an environment variable with your project folder paths, which should be a comma-separated list of directories that contain your project folders. + +You can add however many you’d like, and it’ll work fine if you use Alfred on different machines and add paths relevant to each one. + +Example: `~/dev,~/Documents/Projects` + +## Usage + +Use the `src` keyword to fuzzy search your directories. + +- <kbd>return</kbd> opens the selection in VS Code +- <kbd>⌘</kbd> + <kbd>return</kbd> opens the selection in PhpStorm +- <kbd>shift</kbd> + <kbd>return</kbd> opens the selection in iTerm +- <kbd>ctrl</kbd> + <kbd>return</kbd> reveals the selection in Finder uidata 03869EF0-FFB7-48D5-A211-A974D6A7231B @@ -270,7 +289,50 @@ end CommandRun userconfigurationconfig - + + + config + + default + + placeholder + ~/dev + required + + trim + + + description + Comma-separated directories to be searched for projects. + label + Search Paths + type + textfield + variable + SEARCH_PATHS + + + config + + default + src + placeholder + + required + + trim + + + description + Set your own keyword if you don’t want it to be “src”. + label + Workflow Keyword + type + textfield + variable + keyword + + variables IGNORE_PATTERNS diff --git a/search.php b/search.php deleted file mode 100644 index 377d1fe..0000000 --- a/search.php +++ /dev/null @@ -1,108 +0,0 @@ -logger()->info('PHP version: '.phpversion()); - -if (empty($workflow->env('SEARCH_PATHS'))) { - return; -} - -// Expand ignored file setting into an array -$ignore = array_map( - 'trim', - explode(',', $workflow->env('IGNORE_PATTERNS', '.,..,.DS_Store')) -); - -// Normalize comma-separated setting value into a glob-friendly pattern -$searchPathString = buildSearchPathString( - $workflow->env('SEARCH_PATHS'), - $workflow->env('HOME'), -); - -// Match directories -$matches = glob($searchPathString, GLOB_ONLYDIR | GLOB_BRACE); -$workflow->logger()->info('Search glob: '.$searchPathString); - -// Build a keyed list of directory folder names and full paths -$list = []; - -foreach ($matches as $match) { - $folder = basename($match); - if (!in_array($folder, $ignore, true)) { - $list[] = [ - 'folder' => $folder, - 'path' => $match, - ]; - } -} - -// Prepare Fuse to search folder names and be slightly picky about it -$fuse = new \Fuse\Fuse($list, [ - 'keys' => ['folder'], - 'minMatchCharLength' => 1, - 'threshold' => 0.3, -]); - -$keyword = trim($workflow->argument() ?? ''); - -if (empty($keyword)) { - $results = []; - - foreach ($fuse->getCollection() as $item) { - $results[] = [ - 'item' => $item, - ]; - } -} else { - // Rock and roll - $results = $fuse->search($keyword); - $workflow->logger()->info('Matching results: '.count($results)); -} - -foreach ($results as $result) { - $workflow->item() - ->title($result['item']['folder']) - ->subtitle($result['item']['path']) - ->arg($result['item']['path']) - ->iconForFilePath('/Applications/Visual Studio Code.app') - ->mod(Mod::cmd()->iconForFilePath('/Applications/PhpStorm.app')) - ->mod(Mod::shift()->iconForFilePath('/Applications/iTerm.app')) - ->mod(Mod::ctrl()->iconForFilePath('/System/Library/CoreServices/Finder.app')); -} - -$workflow->output(); - -/** - * Takes the comma-separated setting value of paths, cleans any leading - * or trailing space, expands relative home references (`~/`) into - * absolute paths, and joins them back together into a glob search string - * using braces (`{/path/one,/path/two}/*`). - * - * @param string $settingValue - * @param string $homePath - * @return string - */ -function buildSearchPathString(string $settingValue, string $homePath): string -{ - $searchPaths = array_map(static function($path) use ($homePath) { - $path = trim($path); - if (str_starts_with($path, '~/')) { - // Expand `~/` to full path - $fullHomePath = $homePath.'/'; - return substr_replace( - $path, - $fullHomePath, - 0, - strlen('~/') - ); - } - return $path; - }, explode(',', $settingValue)); - - return '{'.implode(',', $searchPaths).'}/*'; -} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..25981af --- /dev/null +++ b/src/main.rs @@ -0,0 +1,474 @@ +use serde::{Deserialize, Serialize}; +use std::env; +use std::fs; +use std::io::{self, BufRead}; +use nucleo_matcher::{Matcher, Config, Utf32Str}; + +#[derive(Debug, Clone)] +pub struct Project { + folder: String, + path: String, +} + +#[derive(Debug, Clone)] +struct SearchResult { + project: Project, + score: f64, +} + +#[derive(Serialize, Deserialize)] +struct AlfredIcon { + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + icon_type: Option, + path: String, +} + +#[derive(Serialize, Deserialize)] +struct AlfredMod { + arg: String, + subtitle: String, + icon: AlfredIcon, +} + +#[derive(Serialize, Deserialize)] +struct AlfredItem { + title: String, + subtitle: String, + arg: String, + icon: AlfredIcon, + mods: std::collections::HashMap, +} + +#[derive(Serialize, Deserialize)] +struct AlfredResponse { + items: Vec, +} + +struct FuzzyMatcher { + matcher: Matcher, +} + +impl FuzzyMatcher { + fn new() -> Self { + let config = Config::DEFAULT; + Self { + matcher: Matcher::new(config), + } + } + + fn search(&mut self, query: &str, projects: &[Project]) -> Vec { + let query = query.trim(); + + if query.is_empty() { + let mut results: Vec = projects + .iter() + .map(|p| SearchResult { + project: p.clone(), + score: 1.0, + }) + .collect(); + + results.sort_by(|a, b| a.project.folder.to_lowercase().cmp(&b.project.folder.to_lowercase())); + return results; + } + + // Create buffers for UTF-32 conversion + let mut query_chars = Vec::new(); + let query_utf32 = Utf32Str::new(query, &mut query_chars); + + let mut results: Vec = projects + .iter() + .filter_map(|project| { + // Create buffer for each project folder + let mut folder_chars = Vec::new(); + let folder_utf32 = Utf32Str::new(&project.folder, &mut folder_chars); + + // Use nucleo-matcher to score the match + if let Some(score) = self.matcher.fuzzy_match(folder_utf32, query_utf32) { + // Convert nucleo score (u16) to f64 and normalize + let normalized_score = score as f64 / 1000.0; // nucleo scores are typically 0-1000+ + Some(SearchResult { + project: project.clone(), + score: normalized_score, + }) + } else { + None + } + }) + .collect(); + + // Sort by score descending + results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + results + } +} + +pub fn expand_home_path(path: &str, home_path: &str) -> String { + if path.starts_with("~/") { + format!("{}/{}", home_path, &path[2..]) + } else { + path.to_string() + } +} + +pub fn find_projects(search_paths: &str, ignore_patterns_string: &str, home_path: &str) -> Vec { + let ignore_patterns: Vec<&str> = if ignore_patterns_string.is_empty() { + vec![".", "..", ".DS_Store"] + } else { + ignore_patterns_string.split(',').map(|s| s.trim()).collect() + }; + + let paths: Vec = search_paths + .split(',') + .map(|path| expand_home_path(path.trim(), home_path)) + .collect(); + + let mut projects = Vec::new(); + + for base_path in paths { + if let Ok(entries) = fs::read_dir(&base_path) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_dir() { + if let Some(folder_name) = entry.file_name().to_str() { + if !ignore_patterns.contains(&folder_name) { + projects.push(Project { + folder: folder_name.to_string(), + path: entry.path().to_string_lossy().to_string(), + }); + } + } + } + } + } + } + } + + projects +} + +fn create_alfred_output(results: &[SearchResult]) -> String { + let items: Vec = results + .iter() + .map(|result| { + let mut mods = std::collections::HashMap::new(); + + mods.insert( + "cmd".to_string(), + AlfredMod { + arg: result.project.path.clone(), + subtitle: "Open in PhpStorm".to_string(), + icon: AlfredIcon { + icon_type: Some("fileicon".to_string()), + path: "/Applications/PhpStorm.app".to_string(), + }, + }, + ); + + mods.insert( + "shift".to_string(), + AlfredMod { + arg: result.project.path.clone(), + subtitle: "Open in iTerm".to_string(), + icon: AlfredIcon { + icon_type: Some("fileicon".to_string()), + path: "/Applications/iTerm.app".to_string(), + }, + }, + ); + + mods.insert( + "ctrl".to_string(), + AlfredMod { + arg: result.project.path.clone(), + subtitle: "Reveal in Finder".to_string(), + icon: AlfredIcon { + icon_type: Some("fileicon".to_string()), + path: "/System/Library/CoreServices/Finder.app".to_string(), + }, + }, + ); + + AlfredItem { + title: result.project.folder.clone(), + subtitle: result.project.path.clone(), + arg: result.project.path.clone(), + icon: AlfredIcon { + icon_type: Some("fileicon".to_string()), + path: "/Applications/Visual Studio Code.app".to_string(), + }, + mods, + } + }) + .collect(); + + let response = AlfredResponse { items }; + + serde_json::to_string(&response).unwrap_or_else(|_| r#"{"items":[]}"#.to_string()) +} + +pub fn process_search_request(query: &str, search_paths: &str, ignore_patterns: &str, home_path: &str) -> String { + if search_paths.is_empty() { + let warning_response = AlfredResponse { + items: vec![AlfredItem { + title: "Search paths not configured".to_string(), + subtitle: "Add directories for the Quick Open Project workflow".to_string(), + arg: "alfred://workflow/com.mattstein.quick-open-project".to_string(), + icon: AlfredIcon { + icon_type: None, + path: "./warning.png".to_string(), + }, + mods: std::collections::HashMap::new(), + }], + }; + return serde_json::to_string(&warning_response).unwrap_or_else(|_| r#"{"items":[]}"#.to_string()); + } + + let projects = find_projects(search_paths, ignore_patterns, home_path); + let mut matcher = FuzzyMatcher::new(); + let results = matcher.search(query, &projects); + create_alfred_output(&results) +} + +fn main() { + let args: Vec = env::args().collect(); + let mut query = String::new(); + + // Try command line arguments first + if args.len() > 1 { + query = args[1].clone(); + } else { + // If no command line args, try reading from STDIN + if let Some(Ok(line)) = io::stdin().lock().lines().next() { + query = line.trim().to_string(); + } + } + + let query = query.trim(); + let search_paths_env = env::var("SEARCH_PATHS").unwrap_or_default(); + let ignore_patterns_env = env::var("IGNORE_PATTERNS").unwrap_or_else(|_| ".,..,.DS_Store".to_string()); + let home_path_env = env::var("HOME").unwrap_or_else(|_| "/Users".to_string()); + + let output = process_search_request(query, &search_paths_env, &ignore_patterns_env, &home_path_env); + println!("{}", output); +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + use std::fs; + + fn create_test_projects(base_dir: &std::path::Path) -> Vec<&'static str> { + let project_names = vec!["project-one", "another-project", "test-app", "my-website"]; + for name in &project_names { + fs::create_dir_all(base_dir.join(name)).unwrap(); + } + project_names + } + + #[test] + fn test_empty_search_paths_warning() { + let result = process_search_request("test", "", ".,..,.DS_Store", "/home/user"); + + // Parse JSON to verify structure + let parsed: AlfredResponse = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed.items.len(), 1); + + let item = &parsed.items[0]; + assert!(item.title.contains("Search paths not configured")); + assert!(item.subtitle.contains("directories for the Quick Open Project workflow")); + assert!(item.arg.contains("alfred://workflow")); + assert_eq!(item.icon.icon_type, None); + assert_eq!(item.icon.path, "./warning.png"); + } + + #[test] + fn test_home_directory_expansion() { + // Test basic expansion + let result = expand_home_path("~/Documents/Projects", "/Users/testuser"); + assert_eq!(result, "/Users/testuser/Documents/Projects"); + + // Test path that doesn't start with ~/ + let result = expand_home_path("/absolute/path", "/Users/testuser"); + assert_eq!(result, "/absolute/path"); + + // Test just ~/ + let result = expand_home_path("~/", "/Users/testuser"); + assert_eq!(result, "/Users/testuser/"); + } + + #[test] + fn test_find_projects_with_temp_directories() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path(); + + // Create test project directories + let project_names = create_test_projects(temp_path); + + // Test finding projects + let projects = find_projects( + &temp_path.to_string_lossy(), + ".,..,.DS_Store", + "/tmp" + ); + + // Should find all created projects + assert_eq!(projects.len(), project_names.len()); + + // Verify project names are found + let found_names: Vec<&str> = projects.iter().map(|p| p.folder.as_str()).collect(); + for name in project_names { + assert!(found_names.contains(&name)); + } + } + + #[test] + fn test_find_projects_with_home_expansion() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path(); + let fake_home = temp_path.to_string_lossy().to_string(); + + // Create a subdirectory to simulate ~/Projects + let projects_dir = temp_path.join("Projects"); + fs::create_dir_all(&projects_dir).unwrap(); + create_test_projects(&projects_dir); + + // Test with ~ expansion + let projects = find_projects("~/Projects", ".,..,.DS_Store", &fake_home); + assert_eq!(projects.len(), 4); + } + + #[test] + fn test_find_projects_ignores_patterns() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path(); + + // Create normal projects + create_test_projects(temp_path); + + // Create directories that should be ignored + fs::create_dir_all(temp_path.join(".git")).unwrap(); + fs::create_dir_all(temp_path.join("node_modules")).unwrap(); + + // Test with custom ignore patterns + let projects = find_projects( + &temp_path.to_string_lossy(), + ".git,node_modules,.,..,.DS_Store", + "/tmp" + ); + + // Should only find the 4 real projects, not the ignored ones + assert_eq!(projects.len(), 4); + + // Verify ignored directories are not included + let found_names: Vec<&str> = projects.iter().map(|p| p.folder.as_str()).collect(); + assert!(!found_names.contains(&".git")); + assert!(!found_names.contains(&"node_modules")); + } + + #[test] + fn test_search_with_real_projects() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path(); + let fake_home = temp_path.to_string_lossy().to_string(); + + create_test_projects(temp_path); + + // Test search with query + let result = process_search_request( + "test", + &temp_path.to_string_lossy(), + ".,..,.DS_Store", + &fake_home + ); + + // Parse and verify results + let parsed: AlfredResponse = serde_json::from_str(&result).unwrap(); + assert!(!parsed.items.is_empty()); + + // Should find test-app project + let found_test_app = parsed.items.iter().any(|item| item.title == "test-app"); + assert!(found_test_app); + } + + #[test] + fn test_search_empty_query_returns_all() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path(); + let fake_home = temp_path.to_string_lossy().to_string(); + + create_test_projects(temp_path); + + // Test search with empty query + let result = process_search_request( + "", + &temp_path.to_string_lossy(), + ".,..,.DS_Store", + &fake_home + ); + + // Parse and verify results + let parsed: AlfredResponse = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed.items.len(), 4); // Should return all projects + } + + #[test] + fn test_search_fuzzy_matching() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path(); + let fake_home = temp_path.to_string_lossy().to_string(); + + create_test_projects(temp_path); + + // Test fuzzy search with "proj" should match "project-one" and "another-project" + let result = process_search_request( + "proj", + &temp_path.to_string_lossy(), + ".,..,.DS_Store", + &fake_home + ); + + let parsed: AlfredResponse = serde_json::from_str(&result).unwrap(); + assert!(!parsed.items.is_empty()); + + // Should find projects containing "proj" + let project_titles: Vec<&str> = parsed.items.iter().map(|item| item.title.as_str()).collect(); + assert!(project_titles.contains(&"project-one") || project_titles.contains(&"another-project")); + } + + #[test] + fn test_alfred_output_structure() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path(); + let fake_home = temp_path.to_string_lossy().to_string(); + + create_test_projects(temp_path); + + let result = process_search_request( + "project", + &temp_path.to_string_lossy(), + ".,..,.DS_Store", + &fake_home + ); + + let parsed: AlfredResponse = serde_json::from_str(&result).unwrap(); + assert!(!parsed.items.is_empty()); + + // Verify Alfred item structure + let item = &parsed.items[0]; + assert!(!item.title.is_empty()); + assert!(!item.subtitle.is_empty()); + assert!(!item.arg.is_empty()); + assert_eq!(item.icon.icon_type, Some("fileicon".to_string())); + assert!(item.icon.path.contains("Visual Studio Code")); + + // Verify modifier keys + assert!(item.mods.contains_key("cmd")); + assert!(item.mods.contains_key("shift")); + assert!(item.mods.contains_key("ctrl")); + + let cmd_mod = &item.mods["cmd"]; + assert!(cmd_mod.subtitle.contains("PhpStorm")); + } +} \ No newline at end of file diff --git a/warning.png b/warning.png new file mode 100644 index 0000000..89cb964 Binary files /dev/null and b/warning.png differ