Skip to content

Commit 7de50c7

Browse files
committed
feat: arrow-key picker when 'tunnel listen' is called with no flags
Running 'tunnel listen' bare now pops an interactive picker on a TTY, listing existing tunnels with hostname → endpoint [label]. Pick one with ↑↓/Enter to resume it (treated as if --id had been given). Tunnels without hostnames (still pending) are excluded — picking them would just produce 'tunnel not found'. Enabled tunnels sort above disabled (marked '○') so the most-likely-relevant ones come first. Non-TTY (CI, piped) keeps the existing fail-fast error so scripts don't hang on stdin. Empty candidate list (no tunnels in the project, or no tunnels with hostnames yet) also falls through to the error — there's nothing to pick. Adds inquire 0.9 for the picker. Considered rolling raw crossterm to avoid a dep, but the cost/benefit didn't justify it for one prompt.
1 parent a68d8ae commit 7de50c7

3 files changed

Lines changed: 154 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 69 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ z32 = "1.0.3"
2323
rand.workspace = true
2424
hex.workspace = true
2525
sentry.workspace = true
26-
rustls = { workspace = true, features = ["ring"] }
26+
rustls = { workspace = true, features = ["ring"] }
27+
inquire = "0.9.4"

cli/src/main.rs

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -647,15 +647,27 @@ async fn main() -> n0_error::Result<()> {
647647
}
648648
let stored_endpoint = t.endpoint.clone();
649649
(Some(t), stored_endpoint)
650-
} else {
651-
let Some(endpoint) = endpoint else {
652-
n0_error::bail_any!(
653-
"Provide --endpoint <addr> (or --id <tunnel-id> to resume \
654-
an existing tunnel). See 'datum-connect tunnel listen --help'."
655-
);
656-
};
650+
} else if let Some(endpoint) = endpoint {
657651
let existing = service.get_active_by_endpoint(&endpoint).await?;
658652
(existing, endpoint)
653+
} else {
654+
// Neither flag was given. If we're on a TTY and the
655+
// project has tunnels with hostnames, pop a picker.
656+
// Otherwise fall back to the usual error so the
657+
// failure mode in scripts/CI stays explicit.
658+
match pick_tunnel_interactive(&service).await? {
659+
Some(t) => {
660+
let endpoint = t.endpoint.clone();
661+
(Some(t), endpoint)
662+
}
663+
None => {
664+
n0_error::bail_any!(
665+
"Provide --endpoint <addr> (or --id <tunnel-id> to resume \
666+
an existing tunnel). See \
667+
'datum-connect tunnel listen --help'."
668+
);
669+
}
670+
}
659671
};
660672
let tunnel_id = if let Some(t) = existing {
661673
println!("Found existing tunnel for {}:", endpoint);
@@ -909,6 +921,70 @@ async fn select_project_interactive(datum: &DatumCloudClient) -> n0_error::Resul
909921
Ok(())
910922
}
911923

924+
/// Interactive picker for resuming an existing tunnel. Returns the
925+
/// chosen tunnel, or `None` if the user cancelled, there are no
926+
/// candidates, or stdin is not a TTY (in which case the caller should
927+
/// fall back to its usual flag-missing error path).
928+
async fn pick_tunnel_interactive(
929+
service: &TunnelService,
930+
) -> n0_error::Result<Option<lib::TunnelSummary>> {
931+
use std::io::IsTerminal;
932+
933+
if !std::io::stdin().is_terminal() {
934+
return Ok(None);
935+
}
936+
937+
let mut candidates: Vec<lib::TunnelSummary> = service
938+
.list_active()
939+
.await?
940+
.into_iter()
941+
.filter(|t| !t.hostnames.is_empty())
942+
.collect();
943+
if candidates.is_empty() {
944+
return Ok(None);
945+
}
946+
// Most-likely-relevant first: tunnels you previously enabled (have an
947+
// advertisement) bubble up before disabled ones.
948+
candidates.sort_by(|a, b| {
949+
b.enabled
950+
.cmp(&a.enabled)
951+
.then_with(|| a.hostnames[0].cmp(&b.hostnames[0]))
952+
});
953+
954+
let max_host = candidates
955+
.iter()
956+
.map(|t| t.hostnames[0].len())
957+
.max()
958+
.unwrap_or(0);
959+
let max_endpoint = candidates
960+
.iter()
961+
.map(|t| t.endpoint.len())
962+
.max()
963+
.unwrap_or(0);
964+
let labels: Vec<String> = candidates
965+
.iter()
966+
.map(|t| {
967+
let host = &t.hostnames[0];
968+
let status = if t.enabled { " " } else { "○ " };
969+
format!(
970+
"{status}{host:<host_w$} → {endpoint:<ep_w$} [{label}]",
971+
host_w = max_host,
972+
ep_w = max_endpoint,
973+
endpoint = t.endpoint,
974+
label = t.label,
975+
)
976+
})
977+
.collect();
978+
979+
let answer = inquire::Select::new("Resume which tunnel?", labels)
980+
.with_help_message("↑↓ navigate · Enter select · Esc cancel · ○ = disabled")
981+
.with_page_size(10)
982+
.raw_prompt_skippable()
983+
.map_err(|err| n0_error::anyerr!("interactive selection failed: {err}"))?;
984+
985+
Ok(answer.map(|opt| candidates[opt.index].clone()))
986+
}
987+
912988
/// Find a project by ID across all orgs and build a `SelectedContext` for it.
913989
fn resolve_project_context(
914990
orgs: &[lib::datum_cloud::OrganizationWithProjects],

0 commit comments

Comments
 (0)