use std::{
fmt,
path::{Path, PathBuf},
time::SystemTime,
};
use anyhow::{Result, bail};
use inquire::{InquireError, Select};
use crate::{discovery, transcript::Folio};
pub fn pick_session(root: &Path, cwd: &Path) -> Result<PathBuf> {
let quire = pick_project(root, cwd)?;
pick_from_quire(&quire)
}
fn pick_project(root: &Path, cwd: &Path) -> Result<discovery::Quire> {
let mut quires = discovery::all_quires(root)?;
if quires.is_empty() {
bail!("no recorded sessions under {}", root.display());
}
let current = discovery::encode_project_path(cwd);
if let Some(here) = quires
.iter()
.position(|quire| quire.dir.file_name().is_some_and(|name| name == &*current))
{
quires[..=here].rotate_right(1);
}
let choices = quires.into_iter().map(ProjectChoice::new).collect();
select("Project", choices).map(|choice| choice.quire)
}
fn pick_from_quire(quire: &discovery::Quire) -> Result<PathBuf> {
let choices = quire
.sessions
.iter()
.map(|session| SessionChoice::new(session.clone()))
.collect();
select("Session", choices).map(|choice| choice.path)
}
struct ProjectChoice {
quire: discovery::Quire,
label: String,
}
impl ProjectChoice {
fn new(quire: discovery::Quire) -> Self {
let label = quire
.latest()
.ok()
.map(Folio::peek)
.and_then(|peek| peek.cwd)
.map(|cwd| cwd.display().to_string())
.unwrap_or_else(|| quire.dir.display().to_string());
Self { quire, label }
}
}
impl fmt::Display for ProjectChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.label)
}
}
struct SessionChoice {
path: PathBuf,
label: String,
}
impl SessionChoice {
fn new(path: PathBuf) -> Self {
let when = path
.metadata()
.and_then(|metadata| metadata.modified())
.map(relative_time)
.unwrap_or_else(|_| "?".to_owned());
let title = Folio::peek(&path)
.title
.map(|title| condense(&title))
.unwrap_or_else(|| "(untitled)".to_owned());
Self {
label: format!("{when:>9} {title}"),
path,
}
}
}
impl fmt::Display for SessionChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.label)
}
}
fn select<T: fmt::Display>(message: &str, choices: Vec<T>) -> Result<T> {
match Select::new(message, choices).with_page_size(15).prompt() {
Ok(choice) => Ok(choice),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
bail!("no session selected")
}
Err(error) => Err(error.into()),
}
}
fn condense(title: &str) -> String {
const MAX: usize = 72;
let line: String = title.split_whitespace().collect::<Vec<_>>().join(" ");
match line.char_indices().nth(MAX) {
Some((cut, _)) => format!("{}…", &line[..cut]),
None => line,
}
}
fn relative_time(modified: SystemTime) -> String {
let Ok(elapsed) = SystemTime::now().duration_since(modified) else {
return "just now".to_owned();
};
let seconds = elapsed.as_secs();
if seconds < 60 {
"just now".to_owned()
} else if seconds < 3600 {
format!("{}m ago", seconds / 60)
} else if seconds < 86_400 {
format!("{}h ago", seconds / 3600)
} else {
format!("{}d ago", seconds / 86_400)
}
}