Display shortest unique key prefix in output
Keys now show 8 characters with the unique prefix bolded and the
remainder dimmed, matching jj's change ID style. Users can see
at a glance how much of a key they need to type.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index 312335b..7874ef4 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -1,6 +1,7 @@
use clap::Subcommand;
use color_eyre::eyre::Result;
use ranger::db::SqlitePool;
+use ranger::key;
use ranger::models::{Backlog, State};
use ranger::ops;
@@ -67,6 +68,9 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
});
println!("{}", serde_json::to_string_pretty(&detail).unwrap());
} else {
+ let all_keys = ops::task::all_keys(&mut conn).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
+
print_backlog_detail(&backlog);
for state in [State::Done, State::InProgress, State::Queued, State::Icebox] {
@@ -74,7 +78,11 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
if !tasks.is_empty() {
println!("\n[{}]", state);
for t in &tasks {
- println!(" {} {}", &t.key[..8], t.title);
+ println!(
+ " {} {}",
+ output::format_key_from_map(&t.key, &prefixes),
+ t.title
+ );
}
}
}
diff --git a/src/bin/ranger/commands/blocker.rs b/src/bin/ranger/commands/blocker.rs
index 1842f3a..38781c9 100644
--- a/src/bin/ranger/commands/blocker.rs
+++ b/src/bin/ranger/commands/blocker.rs
@@ -1,6 +1,7 @@
use clap::Subcommand;
use color_eyre::eyre::Result;
use ranger::db::SqlitePool;
+use ranger::key;
use ranger::ops;
use crate::output;
@@ -32,16 +33,29 @@ pub async fn run(pool: &SqlitePool, command: BlockerCommands, json: bool) -> Res
BlockerCommands::Add { task, blocked_by } => {
let t = ops::task::get_by_key_prefix(&mut conn, &task).await?;
let bt = ops::task::get_by_key_prefix(&mut conn, &blocked_by).await?;
+ let all_keys = ops::task::all_keys(&mut conn).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
let blocker = ops::blocker::add(&mut conn, t.id, bt.id).await?;
output::print(&blocker, json, |_| {
- println!("{} blocked by {} {}", &t.key[..8], &bt.key[..8], bt.title);
+ println!(
+ "{} blocked by {} {}",
+ output::format_key_from_map(&t.key, &prefixes),
+ output::format_key_from_map(&bt.key, &prefixes),
+ bt.title
+ );
});
}
BlockerCommands::Remove { task, blocked_by } => {
let t = ops::task::get_by_key_prefix(&mut conn, &task).await?;
let bt = ops::task::get_by_key_prefix(&mut conn, &blocked_by).await?;
+ let all_keys = ops::task::all_keys(&mut conn).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
ops::blocker::remove(&mut conn, t.id, bt.id).await?;
- println!("Removed blocker {} from {}", &bt.key[..8], &t.key[..8]);
+ println!(
+ "Removed blocker {} from {}",
+ output::format_key_from_map(&bt.key, &prefixes),
+ output::format_key_from_map(&t.key, &prefixes)
+ );
}
}
Ok(())
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index f863c9d..ba72eff 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -1,6 +1,9 @@
+use std::collections::HashMap;
+
use clap::{Args, Subcommand};
use color_eyre::eyre::{Result, bail};
use ranger::db::{SqliteConnection, SqlitePool};
+use ranger::key;
use ranger::models::{State, Task};
use ranger::ops;
use ranger::ops::task::Placement;
@@ -177,16 +180,22 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
tx.commit().await?;
- output::print(&task, json, print_task);
+ let mut conn = pool.acquire().await?;
+ let all_keys = ops::task::all_keys(&mut conn).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
+ output::print(&task, json, |t| print_task(t, &prefixes));
}
TaskCommands::List { backlog, state } => {
let mut conn = pool.acquire().await?;
let state = state.map(|s| s.parse::<State>()).transpose()?;
+ let all_keys = ops::task::all_keys(&mut conn).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
+
if let Some(backlog_name) = &backlog {
let bl = ops::backlog::get_by_name(&mut conn, backlog_name).await?;
let tasks = ops::task::list(&mut conn, bl.id, state).await?;
- output::print_list(&tasks, json, print_task);
+ output::print_list(&tasks, json, |t| print_task(t, &prefixes));
} else {
// List all tasks (no backlog filter)
let backlogs = ops::backlog::list(&mut conn).await?;
@@ -199,7 +208,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
}
}
}
- output::print_list(&all_tasks, json, print_task);
+ output::print_list(&all_tasks, json, |t| print_task(t, &prefixes));
}
}
TaskCommands::Show { key } => {
@@ -218,7 +227,10 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
});
println!("{}", serde_json::to_string_pretty(&detail).unwrap());
} else {
- print_task_detail(&task);
+ let all_keys = ops::task::all_keys(&mut conn).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
+
+ print_task_detail(&task, &prefixes);
if !tags.is_empty() {
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
println!("Tags: {}", tag_names.join(", "));
@@ -228,7 +240,11 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
for b in &blockers {
if let Ok(bt) = ops::task::get_by_id(&mut conn, b.blocked_by_task_id).await
{
- println!(" {} {}", &bt.key[..8], bt.title);
+ println!(
+ " {} {}",
+ output::format_key_from_map(&bt.key, &prefixes),
+ bt.title
+ );
}
}
}
@@ -266,7 +282,9 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
ops::task::move_task(&mut conn, &updated, anchors.as_placement()).await?;
}
- output::print(&updated, json, print_task);
+ let all_keys = ops::task::all_keys(&mut conn).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
+ output::print(&updated, json, |t| print_task(t, &prefixes));
}
TaskCommands::Move { key, position } => {
let mut conn = pool.acquire().await?;
@@ -276,7 +294,13 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
match anchors {
Some(anchors) => {
ops::task::move_task(&mut conn, &task, anchors.as_placement()).await?;
- println!("Moved {} {}", &task.key[..8], task.title);
+ let all_keys = ops::task::all_keys(&mut conn).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
+ println!(
+ "Moved {} {}",
+ output::format_key_from_map(&task.key, &prefixes),
+ task.title
+ );
}
None => bail!("--before or --after is required"),
}
@@ -284,19 +308,30 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
TaskCommands::Delete { key } => {
let mut conn = pool.acquire().await?;
let task = ops::task::get_by_key_prefix(&mut conn, &key).await?;
+ let all_keys = ops::task::all_keys(&mut conn).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
ops::task::delete(&mut conn, task.id).await?;
- println!("Deleted {} {}", &task.key[..8], task.title);
+ println!(
+ "Deleted {} {}",
+ output::format_key_from_map(&task.key, &prefixes),
+ task.title
+ );
}
}
Ok(())
}
-fn print_task(t: &Task) {
- println!("{} [{}] {}", &t.key[..8], t.state, t.title);
+fn print_task(t: &Task, prefixes: &HashMap<String, usize>) {
+ println!(
+ "{} [{}] {}",
+ output::format_key_from_map(&t.key, prefixes),
+ t.state,
+ t.title
+ );
}
-fn print_task_detail(t: &Task) {
- println!("Key: {}", t.key);
+fn print_task_detail(t: &Task, prefixes: &HashMap<String, usize>) {
+ println!("Key: {}", output::format_key_from_map(&t.key, prefixes));
println!("Title: {}", t.title);
println!("State: {}", t.state);
if let Some(desc) = &t.description {
diff --git a/src/bin/ranger/output.rs b/src/bin/ranger/output.rs
index 0b6719b..98cfe06 100644
--- a/src/bin/ranger/output.rs
+++ b/src/bin/ranger/output.rs
@@ -1,4 +1,5 @@
use serde::Serialize;
+use std::collections::HashMap;
pub fn print<T: Serialize + std::fmt::Debug>(value: &T, json: bool, human: impl FnOnce(&T)) {
if json {
@@ -17,3 +18,22 @@ pub fn print_list<T: Serialize + std::fmt::Debug>(values: &[T], json: bool, huma
}
}
}
+
+/// The number of key characters to display (matching jj's short change ID style).
+const DISPLAY_LEN: usize = 8;
+
+/// Format a key for display: unique prefix in bold, remainder (up to DISPLAY_LEN) in dim.
+/// If the terminal doesn't support colors, returns the plain 8-char prefix.
+pub fn format_key(key: &str, prefix_len: usize) -> String {
+ let show = &key[..DISPLAY_LEN.min(key.len())];
+ let unique = prefix_len.min(show.len());
+ let bold = &show[..unique];
+ let dim = &show[unique..];
+ format!("\x1b[1m{bold}\x1b[0m\x1b[2m{dim}\x1b[0m")
+}
+
+/// Shorthand: format a key when you only have a single key and its prefix length.
+pub fn format_key_from_map(key: &str, prefix_lengths: &HashMap<String, usize>) -> String {
+ let len = prefix_lengths.get(key).copied().unwrap_or(DISPLAY_LEN);
+ format_key(key, len)
+}
diff --git a/src/key.rs b/src/key.rs
index d048554..bdbba7a 100644
--- a/src/key.rs
+++ b/src/key.rs
@@ -18,6 +18,28 @@ pub fn generate_key() -> String {
.collect()
}
+/// Returns the minimum prefix length needed to uniquely identify `key` among `all_keys`.
+/// The minimum returned value is 1 (even if the set has only one key).
+pub fn shortest_unique_prefix_len(key: &str, all_keys: &[String]) -> usize {
+ let mut len = 1;
+ while len < key.len() {
+ let prefix = &key[..len];
+ let count = all_keys.iter().filter(|k| k.starts_with(prefix)).count();
+ if count <= 1 {
+ return len;
+ }
+ len += 1;
+ }
+ unreachable!("all keys are the same length") // cov-excl-line
+}
+
+/// Builds a map from key → shortest unique prefix length for all keys in the set.
+pub fn unique_prefix_lengths(keys: &[String]) -> std::collections::HashMap<String, usize> {
+ keys.iter()
+ .map(|k| (k.clone(), shortest_unique_prefix_len(k, keys)))
+ .collect()
+}
+
pub fn resolve_prefix(prefix: &str, keys: &[String]) -> Result<String, RangerError> {
let matches: Vec<&String> = keys.iter().filter(|k| k.starts_with(prefix)).collect();
match matches.len() {
@@ -55,6 +77,46 @@ mod tests {
assert_eq!(keys.len(), unique.len());
}
+ #[test]
+ fn shortest_unique_prefix_single_key() {
+ let keys = vec!["romoqtuw".to_string()];
+ assert_eq!(shortest_unique_prefix_len("romoqtuw", &keys), 1);
+ }
+
+ #[test]
+ fn shortest_unique_prefix_diverges_at_second_char() {
+ let keys = vec!["romoqtuw".to_string(), "rypqxnkl".to_string()];
+ // Both start with 'r', diverge at char 2
+ assert_eq!(shortest_unique_prefix_len("romoqtuw", &keys), 2);
+ assert_eq!(shortest_unique_prefix_len("rypqxnkl", &keys), 2);
+ }
+
+ #[test]
+ fn shortest_unique_prefix_longer_shared() {
+ let keys = vec![
+ "romoqtuw".to_string(),
+ "romxnklp".to_string(),
+ "rypqxnkl".to_string(),
+ ];
+ // "romo" vs "romx" need 4 chars, "ry" needs 2
+ assert_eq!(shortest_unique_prefix_len("romoqtuw", &keys), 4);
+ assert_eq!(shortest_unique_prefix_len("romxnklp", &keys), 4);
+ assert_eq!(shortest_unique_prefix_len("rypqxnkl", &keys), 2);
+ }
+
+ #[test]
+ fn unique_prefix_lengths_builds_map() {
+ let keys = vec![
+ "romoqtuw".to_string(),
+ "rypqxnkl".to_string(),
+ "slmnopqr".to_string(),
+ ];
+ let map = unique_prefix_lengths(&keys);
+ assert_eq!(map["romoqtuw"], 2);
+ assert_eq!(map["rypqxnkl"], 2);
+ assert_eq!(map["slmnopqr"], 1);
+ }
+
#[test]
fn resolve_prefix_exact_match() {
let keys = vec!["romoqtuw".to_string(), "rypqxnkl".to_string()];
diff --git a/src/ops/task.rs b/src/ops/task.rs
index d255af3..2e881df 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -83,6 +83,14 @@ pub async fn list(
Ok(tasks)
}
+/// Fetch all task keys in the database. Used to compute shortest unique prefixes.
+pub async fn all_keys(conn: &mut SqliteConnection) -> Result<Vec<String>, RangerError> {
+ let rows: Vec<(String,)> = sqlx::query_as("SELECT key FROM tasks")
+ .fetch_all(&mut *conn)
+ .await?;
+ Ok(rows.into_iter().map(|(k,)| k).collect())
+}
+
pub async fn get_by_id(conn: &mut SqliteConnection, id: i64) -> Result<Task, RangerError> {
let query = format!("SELECT {TASK_COLUMNS} FROM tasks WHERE id = ?");
let task = sqlx::query_as::<_, Task>(&query)