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.

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