use std::future::Future;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use trusty_common::github_path::parse_remote_url;
use trusty_mpm::client::{DaemonClient, ManagedSessionSummary};
use trusty_mpm::core::remote_url_redact::redact_stored_url;
use trusty_mpm::project::record::repo_url_matches;
use trusty_mpm::project::{Project, local_checkout_for};
use crate::commands::picker_launch_new::LaunchIsolation;
use crate::commands::projects::registry::RegisterInput;
use crate::commands::tmux_attach::AttachOutcome;
use super::new_session_name::NameStep;
use super::state::Input;
const PICK_ROWS: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Target {
Registered {
name: String,
repo: String,
checkout: Option<PathBuf>,
},
Other,
}
pub(crate) type CheckoutOf = fn(&Project) -> Option<PathBuf>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProjectIdentity {
pub(crate) name: String,
pub(crate) repo_url: String,
pub(crate) root: String,
}
pub(crate) type Resolver = fn(&str) -> Option<ProjectIdentity>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct NewProject {
pub(crate) name: String,
pub(crate) repo_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct NewSessionRequest {
pub(crate) register: Option<NewProject>,
pub(crate) repo: String,
pub(crate) label: String,
pub(crate) name_hint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Step {
Redraw,
Ignore,
Cancel,
Reject(String),
Create(NewSessionRequest),
}
#[derive(Debug, Clone)]
pub(crate) struct NewSessionFlow {
targets: Vec<Target>,
selected: usize,
typed: Option<String>,
filter: String,
naming: Option<NameStep>,
resolve: Resolver,
}
impl PartialEq for NewSessionFlow {
fn eq(&self, other: &Self) -> bool {
self.targets == other.targets
&& self.selected == other.selected
&& self.typed == other.typed
&& self.filter == other.filter
&& self.naming == other.naming
}
}
impl Eq for NewSessionFlow {}
impl NewSessionFlow {
pub(crate) fn new(targets: Vec<Target>) -> Self {
Self::with_resolver(targets, derive_identity)
}
pub(crate) fn with_resolver(targets: Vec<Target>, resolve: Resolver) -> Self {
Self {
targets,
selected: 0,
typed: None,
filter: String::new(),
naming: None,
resolve,
}
}
pub(crate) fn preselected_for(mut self, session: Option<&ManagedSessionSummary>) -> Self {
self.selected = preselect_index(&self.targets, session);
self
}
pub(crate) fn typed(&self) -> Option<&str> {
self.typed.as_deref()
}
pub(crate) fn naming(&self) -> Option<&NameStep> {
self.naming.as_ref()
}
pub(crate) fn filter(&self) -> &str {
&self.filter
}
fn visible(&self) -> Vec<usize> {
let needle = self.filter.to_lowercase();
row_labels(&self.targets)
.into_iter()
.enumerate()
.filter(|(i, label)| {
needle.is_empty()
|| matches!(self.targets.get(*i), Some(Target::Other))
|| label.to_lowercase().contains(&needle)
})
.map(|(i, _)| i)
.collect()
}
fn cursor(&self, visible: &[usize]) -> usize {
visible
.iter()
.position(|i| *i == self.selected)
.unwrap_or(0)
}
fn window_start(&self, visible: &[usize]) -> usize {
self.cursor(visible)
.saturating_sub(PICK_ROWS.saturating_sub(1))
}
pub(crate) fn rows(&self) -> Vec<String> {
let labels = row_labels(&self.targets);
let visible = self.visible();
visible
.iter()
.skip(self.window_start(&visible))
.take(PICK_ROWS)
.map(|i| {
let marker = if *i == self.selected { "▸" } else { " " };
let label = labels.get(*i).map_or("", String::as_str);
format!("{marker} {label}")
})
.collect()
}
pub(crate) fn position(&self) -> Option<String> {
let visible = self.visible();
if visible.len() <= PICK_ROWS {
return None;
}
let start = self.window_start(&visible);
let end = (start + PICK_ROWS).min(visible.len());
Some(format!("{}–{} of {}", start + 1, end, visible.len()))
}
pub(crate) fn apply(&mut self, input: Input) -> Step {
if let Some(step) = super::new_session_name::route(&mut self.naming, input) {
return step;
}
match input {
Input::Escape if self.typed.is_none() && !self.filter.is_empty() => {
self.filter.clear();
Step::Redraw
}
Input::Escape => Step::Cancel,
_ if self.typed.is_some() => self.apply_path(input),
_ => self.apply_pick(input),
}
}
fn apply_pick(&mut self, input: Input) -> Step {
let visible = self.visible();
let cursor = self.cursor(&visible);
let last = visible.len().saturating_sub(1);
match input {
Input::Up => self.move_within(&visible, cursor.saturating_sub(1)),
Input::Down => self.move_within(&visible, (cursor + 1).min(last)),
Input::PageUp => self.move_within(&visible, cursor.saturating_sub(PICK_ROWS)),
Input::PageDown => self.move_within(&visible, (cursor + PICK_ROWS).min(last)),
Input::Home => self.move_within(&visible, 0),
Input::End => self.move_within(&visible, last),
Input::Char(c) => {
self.filter.push(c);
self.keep_selection_visible();
Step::Redraw
}
Input::Backspace => {
if self.filter.pop().is_none() {
return Step::Ignore;
}
self.keep_selection_visible();
Step::Redraw
}
Input::Enter => match self.targets.get(self.selected) {
Some(Target::Registered {
name,
repo,
checkout,
}) => match request_for_registered(name, repo, checkout.as_deref()) {
Ok(request) => Step::Create(request),
Err(message) => Step::Reject(message),
},
Some(Target::Other) => self.confirm_escape_row(),
None => Step::Ignore,
},
Input::NameNew => {
super::new_session_name::open(&mut self.naming, self.targets.get(self.selected))
}
_ => Step::Ignore,
}
}
fn confirm_escape_row(&mut self) -> Step {
let typed = self.filter.trim().to_string();
if typed.is_empty() || self.filter_matches_a_registered_row() {
self.typed = Some(String::new());
return Step::Redraw;
}
match super::new_session_entry::request_for_entry(&typed, &self.targets) {
Ok(request) => Step::Create(request),
Err(message) => Step::Reject(message),
}
}
fn filter_matches_a_registered_row(&self) -> bool {
self.visible()
.iter()
.any(|i| matches!(self.targets.get(*i), Some(Target::Registered { .. })))
}
fn move_within(&mut self, visible: &[usize], position: usize) -> Step {
match visible.get(position) {
Some(index) => self.move_to(*index),
None => Step::Ignore,
}
}
fn move_to(&mut self, index: usize) -> Step {
if self.targets.is_empty() || index == self.selected {
return Step::Ignore;
}
self.selected = index;
Step::Redraw
}
fn keep_selection_visible(&mut self) {
let visible = self.visible();
if visible.contains(&self.selected) {
return;
}
self.selected = visible.first().copied().unwrap_or(0);
}
fn apply_path(&mut self, input: Input) -> Step {
let Some(typed) = self.typed.as_mut() else {
return Step::Ignore;
};
match input {
Input::Char(c) => {
typed.push(c);
Step::Redraw
}
Input::Backspace => {
typed.pop();
Step::Redraw
}
Input::Enter => match request_for_path(typed, &self.targets, self.resolve) {
Ok(request) => Step::Create(request),
Err(msg) => Step::Reject(msg),
},
_ => Step::Ignore,
}
}
}
pub(crate) fn request_for_registered(
name: &str,
repo: &str,
checkout: Option<&Path>,
) -> Result<NewSessionRequest, String> {
let shown = redact_stored_url(repo);
let Some(checkout) = checkout else {
return Err(format!(
"{name} is registered as {shown}, which names no project directory — \
re-register it with the path of its checkout"
));
};
let path = checkout.display();
if !checkout.is_dir() {
if shown == repo {
return Err(format!(
"{name} has no checkout at {path} — clone it first: git clone {repo} {path}"
));
}
return Err(format!(
"{name} has no checkout at {path} — clone it first from {shown} into {path}"
));
}
Ok(NewSessionRequest {
register: None,
repo: path.to_string(),
label: name.to_string(),
name_hint: None,
})
}
pub(crate) fn request_for_path(
typed: &str,
targets: &[Target],
resolve: Resolver,
) -> Result<NewSessionRequest, String> {
let trimmed = typed.trim();
if trimmed.is_empty() {
return Err("type the path, clone URL or owner/repo, then Enter".to_string());
}
let Some(id) = resolve(trimmed) else {
return super::new_session_entry::request_for_entry(trimmed, targets).map_err(|e| {
if super::new_session_entry::parse_project_entry(trimmed).is_some() {
return e;
}
format!(
"{trimmed} is not a git checkout, a clone URL, or an \
owner/repo project"
)
});
};
let known = targets.iter().any(|t| match t {
Target::Registered { name, repo, .. } => *name == id.name || *repo == id.repo_url,
Target::Other => false,
});
Ok(NewSessionRequest {
register: (!known).then(|| NewProject {
name: id.name.clone(),
repo_url: id.repo_url,
}),
repo: id.root,
label: id.name,
name_hint: None,
})
}
pub(crate) fn derive_identity(typed: &str) -> Option<ProjectIdentity> {
let (source_id, _workspace, git_root) =
crate::commands::guided::derive_project(Path::new(typed))?;
let name = source_id.rsplit('/').next()?.to_string();
Some(ProjectIdentity {
name,
repo_url: format!("https://github.com/{source_id}"),
root: git_root.to_string_lossy().to_string(),
})
}
pub(crate) async fn fetch_targets(
client: &reqwest::Client,
url: &str,
sessions: &[ManagedSessionSummary],
) -> anyhow::Result<Vec<Target>> {
let projects = DaemonClient::with_client(client.clone(), url.to_string())
.registry_list_projects(None)
.await?;
Ok(targets_from(&projects, sessions))
}
pub(crate) fn targets_from(
projects: &[Project],
sessions: &[ManagedSessionSummary],
) -> Vec<Target> {
targets_from_with(projects, sessions, local_checkout_for)
}
pub(crate) fn targets_from_with(
projects: &[Project],
sessions: &[ManagedSessionSummary],
checkout_of: CheckoutOf,
) -> Vec<Target> {
super::new_session_order::ordered_targets(projects, sessions, checkout_of)
}
pub(crate) fn row_labels(targets: &[Target]) -> Vec<String> {
let labels: Vec<Option<Label>> = targets
.iter()
.map(|t| match t {
Target::Registered { name, repo, .. } => Some(label_for(name, repo)),
Target::Other => None,
})
.collect();
labels
.iter()
.map(|label| match label {
Some(Label::Remote { host, owner, repo }) => {
if collides_across_hosts(&labels, host, owner, repo) {
format!("{host}/{owner}/{repo}")
} else {
format!("{owner}/{repo}")
}
}
Some(Label::Local(base)) => format!("{base} (local)"),
Some(Label::Plain(name)) => name.clone(),
None => "other — type a project path…".to_string(),
})
.collect()
}
enum Label {
Remote {
host: String,
owner: String,
repo: String,
},
Local(String),
Plain(String),
}
fn label_for(name: &str, repo: &str) -> Label {
if let Ok(remote) = parse_remote_url(repo) {
return Label::Remote {
host: remote.host,
owner: remote.owner,
repo: remote.repo,
};
}
let trimmed = repo.trim();
if trimmed.is_empty() {
return Label::Local(name.to_string());
}
if trimmed.starts_with('/') {
let base = Path::new(trimmed)
.file_name()
.map(|b| b.to_string_lossy().to_string())
.unwrap_or_else(|| name.to_string());
return Label::Local(base);
}
Label::Plain(name.to_string())
}
fn collides_across_hosts(labels: &[Option<Label>], host: &str, owner: &str, repo: &str) -> bool {
labels.iter().flatten().any(|l| {
matches!(
l,
Label::Remote {
host: h,
owner: o,
repo: r,
} if o.eq_ignore_ascii_case(owner)
&& r.eq_ignore_ascii_case(repo)
&& !h.eq_ignore_ascii_case(host)
)
})
}
pub(crate) fn preselect_index(
targets: &[Target],
session: Option<&ManagedSessionSummary>,
) -> usize {
let Some(session) = session else { return 0 };
targets
.iter()
.position(|t| is_session_project(t, session))
.unwrap_or(0)
}
fn is_session_project(target: &Target, session: &ManagedSessionSummary) -> bool {
let Target::Registered { repo, .. } = target else {
return false;
};
repo_is_session_project(repo, session)
}
pub(crate) fn repo_is_session_project(repo: &str, session: &ManagedSessionSummary) -> bool {
if let Some(url) = session.repo_url.as_deref()
&& !url.trim().is_empty()
&& repo_url_matches(url, repo)
{
return true;
}
let Some(source) = session.source_id.as_deref() else {
return false;
};
parse_remote_url(repo).is_ok_and(|r| r.owner_repo().eq_ignore_ascii_case(source.trim()))
}
pub(crate) async fn perform(
client: &reqwest::Client,
url: &str,
request: NewSessionRequest,
) -> anyhow::Result<AttachOutcome> {
perform_with(
request,
|project: NewProject| async move {
crate::commands::projects::registry::register(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url),
RegisterInput::new(project.name, project.repo_url),
)
.await
},
|repo: String, name_hint: Option<String>| async move {
crate::commands::guided_launch::launch_new_session_and_attach(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url),
&repo,
name_hint.as_deref(),
LaunchIsolation::default(),
)
.await
},
)
.await
}
pub(crate) async fn perform_with<R, RFut, C, CFut>(
request: NewSessionRequest,
register: R,
create: C,
) -> anyhow::Result<AttachOutcome>
where
R: FnOnce(NewProject) -> RFut,
RFut: Future<Output = anyhow::Result<()>>,
C: FnOnce(String, Option<String>) -> CFut,
CFut: Future<Output = anyhow::Result<AttachOutcome>>,
{
let NewSessionRequest {
register: to_register,
repo,
label,
name_hint,
} = request;
if let Some(project) = to_register {
register(project)
.await
.with_context(|| format!("could not register project '{label}'"))?;
}
create(repo, name_hint).await
}