Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed
- Bare `qr go` / `qr g` now opens a lightweight live-filter picker for cached projects while `qr go <project>` keeps the existing direct lookup behavior.

## [0.1.0] - 2026-07-02

### Added
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ serde_json = "1.0.145"
shellexpand = "3.1.1"
shlex = "2.0.1"
toml = "0.9.8"
unicode-width = "0.2.2"
walkdir = "2.5.0"

# OS keychain for API-key storage, via per-platform native backends. All are
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ QuickRunner (`qr`) is a fast Rust CLI for common developer shell workflows: jump

## Features

- `qr go <project>` / `qr g`: fuzzy project lookup backed by a cached scanner (interactive picker on multiple matches)
- `qr go <project>` / `qr g <project>`: fuzzy project lookup backed by a cached scanner; bare `qr go` / `qr g` opens a live filter picker
- `qr run [--watch|--log|--output] <script>` / `qr r`: script runner with watch, log, and passthrough modes
- `qr alias add|list|remove` / `qr a`: shell alias management
- `qr stats` / `qr s`: aggregated command stats from a local SQLite database
Expand All @@ -23,7 +23,7 @@ qr init # config + shell wrapper + initial scan
exec $SHELL # reload so the `qr go` wrapper takes effect
```

`qr init` appends a wrapper function to your shell rc file so `qr go` can change the parent shell's directory — a child process can't do that on its own. The wrapper calls `qr go --print-path` and runs the `cd` in your shell.
`qr init` appends a wrapper function to your shell rc file so `qr go` can change the parent shell's directory — a child process can't do that on its own. The wrapper calls `qr go --print-path` and runs the `cd` in your shell; bare `qr g` now opens the live filter and still prints only the selected path for that wrapper.

## Config

Expand Down
3 changes: 3 additions & 0 deletions src/ai/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,9 @@ mod tests {

#[test]
fn clear_test_env_removes_cross_endpoint_fallback_opt_in() {
// Must hold the shared env lock: mutating process env without it races
// other lib tests that read/write env vars (and poisons the lock on panic).
let _guard = test_env_lock().lock().unwrap();
unsafe {
std::env::set_var(ALLOW_CROSS_ENDPOINT_FALLBACK_ENV, "true");
}
Expand Down
77 changes: 77 additions & 0 deletions src/commands/go.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,42 @@ pub struct GoResult {
pub interactive_ms: u128,
}

pub fn execute_live(config: &AppConfig) -> Result<GoResult> {
if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
return Err(anyhow!("project name required"));
}

let cache = load_or_scan_projects(config)?;
let labels = cache
.projects
.iter()
.map(live_project_choice_label)
.collect::<Vec<_>>();
let picker_start = std::time::Instant::now();
let selected = project_at_picker_index(&cache.projects, picker::pick_live_index(&labels)?)?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Ok(GoResult {
path: selected.path,
interactive_ms: picker_start.elapsed().as_millis(),
})
}

fn project_at_picker_index(
projects: &[ProjectEntry],
index: Option<usize>,
) -> Result<ProjectEntry> {
if projects.is_empty() {
return Err(anyhow!("No projects found. Run `qr scan` to refresh."));
}
let Some(index) = index else {
return Err(anyhow!("Selection cancelled"));
};
projects
.get(index)
.cloned()
.ok_or_else(|| anyhow!("Selection cancelled"))
}

pub fn execute(config: &AppConfig, query: &str) -> Result<GoResult> {
let cache = load_or_scan_projects(config)?;
let matches = rank_matches(&cache.projects, query);
Expand Down Expand Up @@ -55,6 +91,14 @@ fn project_choice_label(entry: &ProjectEntry) -> String {
)
}

fn live_project_choice_label(entry: &ProjectEntry) -> String {
format!(
"{}\t{}",
terminal::escape_untrusted(&entry.name),
terminal::escape_untrusted(&entry.path)
)
}

fn multiple_match_names(entries: &[ProjectEntry]) -> String {
entries
.iter()
Expand Down Expand Up @@ -212,6 +256,20 @@ mod tests {
);
}

#[test]
fn live_project_choice_label_keeps_parenthesized_paths_unambiguous() {
let entry = ProjectEntry {
name: "demo".into(),
path: r"C:\Program Files (x86)\demo".into(),
source: "git".into(),
};

assert_eq!(
live_project_choice_label(&entry),
"demo\tC:\\Program Files (x86)\\demo"
);
}

#[test]
fn multiple_match_names_escape_terminal_controls() {
let entries = vec![
Expand All @@ -228,4 +286,23 @@ mod tests {
];
assert_eq!(multiple_match_names(&entries), "one\\u{1b}[2J, two\\u{7}");
}

#[test]
fn project_at_selected_live_index_returns_matching_project() {
let projects = sample_projects();

let selected = project_at_picker_index(&projects, Some(1)).unwrap();

assert_eq!(selected.name, "orion-api");
}

#[test]
fn project_at_selected_live_index_reports_empty_project_cache() {
let error = project_at_picker_index(&[], None).unwrap_err();

assert!(
error.to_string().contains("No projects found"),
"unexpected error: {error:#}"
);
}
}
8 changes: 8 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,8 @@ db_path = "/tmp/file.db"

#[test]
fn migrate_legacy_config_rewrites_stats_db_path_under_legacy_dir() {
let _guard = test_env_lock().lock().unwrap();
clear_test_env();
let root = tempfile::tempdir().unwrap();
let legacy = root.path().join("legacy");
let new_dir = root.path().join("new");
Expand Down Expand Up @@ -813,6 +815,8 @@ db_path = "{}"

#[test]
fn migrate_legacy_agent_defaults_rewrites_shipped_defaults() {
let _guard = test_env_lock().lock().unwrap();
clear_test_env();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
Expand Down Expand Up @@ -854,6 +858,8 @@ claude = "claude --dangerously-skip-permissions -p"

#[test]
fn migrate_legacy_agent_defaults_preserves_custom_agent_commands() {
let _guard = test_env_lock().lock().unwrap();
clear_test_env();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let custom_codex = "codex exec --model gpt-5";
Expand Down Expand Up @@ -936,6 +942,8 @@ db_path = "{}"

#[test]
fn migrate_legacy_config_preserves_custom_stats_db_filename() {
let _guard = test_env_lock().lock().unwrap();
clear_test_env();
let root = tempfile::tempdir().unwrap();
let legacy = root.path().join("legacy");
let new_dir = root.path().join("new");
Expand Down
9 changes: 5 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,11 @@ fn run_with_config(command: Commands) -> Result<ExitCode> {
let execution = match command {
Commands::Go(args) => {
let query = args.project.join("-");
if query.is_empty() {
anyhow::bail!("project name required");
}
let result = commands::go::execute(&config, &query)?;
let result = if query.is_empty() {
commands::go::execute_live(&config)?
} else {
commands::go::execute(&config, &query)?
};
interactive_ms = result.interactive_ms;
print_go_result(&result, args.print_path)?;
Ok(ExitCode::SUCCESS)
Expand Down
Loading
Loading