Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/target
.DS_Store
*.alfredworkflow
28 changes: 24 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

This is an Alfred workflow written in Rust that integrates with the Zed editor. The workflow provides three main commands:
This is an Alfred workflow written in Rust that integrates with the Zed editor. The workflow provides four main commands:
- `zf`: Search files and open with Zed
- `zr`: Open recent projects (reads from Zed's SQLite database)
- `zd`: Open project directories from a configured folder (configurable via Alfred UI)
- `ze`: Lookup Zed extensions

## Build and Development Commands
Expand Down Expand Up @@ -37,23 +38,42 @@ cargo build

### Key Functionality
- **Database Integration**: Reads from Zed's SQLite database at `~/.config/Zed/db/0-stable/db.sqlite`
- **Query Processing**: Filters workspaces based on command-line arguments
- **Directory Listing**: Lists subdirectories from configured project directories (via `--dirs` flag)
- **Query Processing**: Filters results based on command-line arguments
- **Alfred Output**: Serializes results as JSON for Alfred consumption

### CLI Usage
```bash
./alfred-zed [query] # List recent projects (zr command)
./alfred-zed --dirs [query] # List project directories (zd command)
```

### Data Flow
**Recent Projects (default mode):**
1. Connects to Zed's SQLite database
2. Queries workspaces table for recent projects
3. Converts workspace data to Alfred item format
4. Filters results based on user query
5. Outputs JSON to stdout for Alfred

**Project Directories (`--dirs` mode):**
1. Reads `projects_directories` environment variable
2. Lists all subdirectories from each configured directory
3. Filters and sorts results
4. Outputs JSON to stdout for Alfred

## Alfred Workflow Configuration

The workflow defines three script filters in `workflow/info.plist`:
The workflow defines four filters in `workflow/info.plist`:
- **zf**: File search using Alfred's file filter
- **zr**: Recent projects using the compiled `alfred-zed` binary
- **zr**: Recent projects using `alfred-zed` binary (default mode)
- **zd**: Project directories using `alfred-zed --dirs` (reads from `projects_directories` env var)
- **ze**: Extensions lookup using `jq` to parse Zed's extensions index

### Workflow Variables
The workflow uses user-configurable variables (set via Alfred's workflow configuration UI):
- **projects_directories**: List of directories containing project folders for the `zd` command (one per line)

## Dependencies

External dependencies required:
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,19 @@ You can download workflow file, or compile your own program.

- `zf`: Search file and Open with Zed.
- `zr`: Open recent projects. Data is provided by Zed sqlite file.
- `zd`: Open project directories from configured folders. Configure directories in Alfred's workflow settings.
- `ze`: Lookup extensions, nothing else. So you can remove it by yourself.

## Configuration

### Projects Directories (for `zd` command)

1. Open Alfred Preferences
2. Navigate to Workflows → Zed
3. Click the `[x]` button in the top-right corner to open workflow configuration
4. Set **Projects Directories** with your project folders (one per line), e.g.:
```
~/Projects/github.com/myorg
~/Projects/github.com/other-org
~/Work/clients
```
151 changes: 133 additions & 18 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use dirs::config_dir;
use serde::Serialize;
use std::env;
use std::fs;
use std::io;
use std::path::PathBuf;

mod constants;
use crate::constants::*;
Expand All @@ -13,11 +16,13 @@ struct Workspace {

#[derive(Serialize, Debug)]
struct Item {
uid: i64,
uid: String,
title: String,
subtitle: String,
icon: Icon,
arg: String,
#[serde(skip_serializing_if = "Option::is_none")]
valid: Option<bool>,
}

impl From<Workspace> for Item {
Expand All @@ -27,15 +32,31 @@ impl From<Workspace> for Item {
let path = path.split_at(index).1;
let name = path.trim_end_matches('/').split('/').last().unwrap_or("");
Self {
uid: workspace.workspace_id,
uid: workspace.workspace_id.to_string(),
title: name.to_owned(),
subtitle: path.to_owned(),
icon: Icon::new(path.to_owned()),
arg: path.to_owned(),
valid: None,
}
}
}

impl Item {
fn from_path(path: PathBuf) -> Option<Self> {
let path_str = path.to_str()?.to_owned();
let name = path.file_name()?.to_str()?.to_owned();
Some(Self {
uid: path_str.clone(),
title: name,
subtitle: path_str.clone(),
icon: Icon::new(path_str.clone()),
arg: path_str,
valid: None,
})
}
}

#[derive(Serialize, Debug)]
struct Icon {
path: String,
Expand All @@ -56,36 +77,130 @@ struct Response {
items: Vec<Item>,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
// Alfred passes in a single argument for the user query.
let query = std::env::args().nth(1);
fn list_recent_projects(query: Option<String>) -> Result<Vec<Item>, Box<dyn std::error::Error>> {
let path = config_dir().unwrap().to_str().unwrap().to_owned() + DB_PATH;
let connection = sqlite::open(path).unwrap();
let connection = sqlite::open(path)?;
let stmt = connection.prepare(format!(
"SELECT {DB_FIELD_WORKSPACE_ID}, {DB_FIELD_PATHS}
FROM workspaces
WHERE {DB_FIELD_PATHS} IS NOT NULL
ORDER BY timestamp DESC"
))?;
let mut items = vec![];
for row in stmt.into_iter().map(|row| row.unwrap()) {
let workspace = Workspace {
workspace_id: row.read::<i64, _>(DB_FIELD_WORKSPACE_ID).to_owned(),
local_paths: row.read::<&str, _>(DB_FIELD_PATHS).to_string(),

let mut items: Vec<Item> = stmt
.into_iter()
.filter_map(|row| row.ok())
.map(|row| {
let workspace = Workspace {
workspace_id: row.read::<i64, _>(DB_FIELD_WORKSPACE_ID),
local_paths: row.read::<&str, _>(DB_FIELD_PATHS).to_string(),
};
Item::from(workspace)
})
.collect();

// Filter by query
if let Some(query) = query {
let query_lower = query.to_lowercase();
items.retain(|item| {
item.title.to_lowercase().contains(&query_lower)
|| item.subtitle.to_lowercase().contains(&query_lower)
});
}

Ok(items)
}

fn list_directories(query: Option<String>) -> Result<Vec<Item>, Box<dyn std::error::Error>> {
let dirs_env = env::var("projects_directories").unwrap_or_default();
let home = env::var("HOME").unwrap_or_default();

let mut items: Vec<Item> = Vec::new();

for line in dirs_env.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}

// Expand ~ to home directory
let dir_path = if line.starts_with('~') {
line.replacen('~', &home, 1)
} else {
line.to_string()
};
let item = Item::from(workspace);
items.push(item);

let path = PathBuf::from(&dir_path);
if !path.is_dir() {
continue;
}

// Read directory entries
if let Ok(entries) = fs::read_dir(&path) {
for entry in entries.filter_map(|e| e.ok()) {
let entry_path = entry.path();
if entry_path.is_dir() {
// Skip hidden directories
if let Some(name) = entry_path.file_name() {
if name.to_str().map(|s| s.starts_with('.')).unwrap_or(false) {
continue;
}
}
if let Some(item) = Item::from_path(entry_path) {
items.push(item);
}
}
}
}
}

// filter by query
// Sort by title
items.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));

// Filter by query
if let Some(query) = query {
let arg = query.to_lowercase();
let query_lower = query.to_lowercase();
items.retain(|item| {
item.title.to_lowercase().contains(&arg) || item.subtitle.to_lowercase().contains(&arg)
item.title.to_lowercase().contains(&query_lower)
|| item.subtitle.to_lowercase().contains(&query_lower)
});
}

// Output to Alfred!
serde_json::to_writer(io::stdout(), &Response { items }).unwrap();
Ok(items)
}

fn no_results_item() -> Item {
Item {
uid: "no-results".to_string(),
title: "No results found".to_string(),
subtitle: "Try a different search term".to_string(),
icon: Icon {
path: "icon.png".to_string(),
r#type: "".to_string(),
},
arg: "".to_string(),
valid: Some(false),
}
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();

let (mode, query) = if args.len() > 1 && args[1] == "--dirs" {
("dirs", args.get(2).cloned())
} else {
("recent", args.get(1).cloned())
};

let mut items = match mode {
"dirs" => list_directories(query)?,
_ => list_recent_projects(query)?,
};

if items.is_empty() {
items.push(no_results_item());
}

serde_json::to_writer(io::stdout(), &Response { items })?;
Ok(())
}
Binary file added workflow/alfred-zed
Binary file not shown.
Loading