use std::path::PathBuf;
use std::sync::Arc;
use axum::Router;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use axum::routing::{get, post};
use serde::{Deserialize, Serialize};
use crate::config::{collet_home, project_data_dir};
use super::state::AppState;
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/api/projects", get(list_projects))
.route("/api/projects/switch", post(switch_project))
.route("/api/browse", get(browse_directory))
}
#[derive(Serialize)]
struct ProjectInfo {
id: String,
working_dir: Option<String>,
session_count: usize,
last_active: Option<String>,
}
async fn list_projects(
State(_state): State<Arc<AppState>>,
) -> Result<Json<Vec<ProjectInfo>>, (StatusCode, String)> {
let projects_dir = collet_home(None).join("projects");
if !projects_dir.exists() {
return Ok(Json(vec![]));
}
let mut entries = tokio::fs::read_dir(&projects_dir).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Cannot read projects dir: {e}"),
)
})?;
let mut projects = Vec::new();
while let Some(entry) = entries.next_entry().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Read error: {e}"),
)
})? {
let metadata = match entry.metadata().await {
Ok(m) => m,
Err(_) => continue,
};
if !metadata.is_dir() {
continue;
}
let id = entry.file_name().to_string_lossy().to_string();
let project_path = entry.path();
let sessions_dir = project_path.join("sessions");
let (working_dir, session_count, last_active) = scan_project_sessions(&sessions_dir).await;
let wd = match &working_dir {
Some(p) if p.starts_with("/tmp/") || p.starts_with("/private/tmp/") => continue,
Some(p) => p.clone(),
None => continue,
};
projects.push(ProjectInfo {
id,
working_dir: Some(wd),
session_count,
last_active,
});
}
projects.sort_by(|a, b| b.last_active.cmp(&a.last_active));
Ok(Json(projects))
}
async fn scan_project_sessions(sessions_dir: &PathBuf) -> (Option<String>, usize, Option<String>) {
let mut working_dir: Option<String> = None;
let mut session_count: usize = 0;
let mut last_active: Option<String> = None;
let latest_path = sessions_dir.join("latest.json");
if let Ok(content) = tokio::fs::read_to_string(&latest_path).await
&& let Ok(snap) = serde_json::from_str::<serde_json::Value>(&content)
{
if let Some(ts) = snap.get("timestamp").and_then(|v| v.as_str()) {
last_active = Some(ts.to_string());
}
if let Some(wd) = snap.get("working_dir").and_then(|v| v.as_str()) {
working_dir = Some(wd.to_string());
}
}
if let Ok(mut reader) = tokio::fs::read_dir(sessions_dir).await {
while let Ok(Some(entry)) = reader.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".json") || name == "latest.json" {
continue;
}
session_count += 1;
if working_dir.is_none()
&& let Ok(content) = tokio::fs::read_to_string(entry.path()).await
&& let Ok(snap) = serde_json::from_str::<serde_json::Value>(&content)
&& let Some(wd) = snap.get("working_dir").and_then(|v| v.as_str())
{
working_dir = Some(wd.to_string());
}
if last_active.is_none()
&& let Ok(content) = tokio::fs::read_to_string(entry.path()).await
&& let Ok(snap) = serde_json::from_str::<serde_json::Value>(&content)
&& let Some(ts) = snap.get("timestamp").and_then(|v| v.as_str())
{
let ts = ts.to_string();
if last_active.as_ref().is_none_or(|prev| ts > *prev) {
last_active = Some(ts);
}
}
}
}
(working_dir, session_count, last_active)
}
#[derive(Deserialize)]
struct SwitchRequest {
working_dir: String,
}
#[derive(Serialize)]
struct SwitchResponse {
id: String,
working_dir: String,
}
async fn switch_project(
State(state): State<Arc<AppState>>,
Json(req): Json<SwitchRequest>,
) -> Result<Json<SwitchResponse>, (StatusCode, String)> {
let path = PathBuf::from(&req.working_dir);
if !path.is_dir() {
return Err((
StatusCode::BAD_REQUEST,
format!("Path is not a directory: {}", req.working_dir),
));
}
let project_dir = project_data_dir(&req.working_dir);
if let Err(e) = tokio::fs::create_dir_all(&project_dir).await {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create project dir: {e}"),
));
}
let id = project_dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
state.set_working_dir(req.working_dir.clone()).await;
*state.context.lock().await = None;
Ok(Json(SwitchResponse {
id,
working_dir: req.working_dir,
}))
}
#[derive(Deserialize)]
struct BrowseQuery {
path: Option<String>,
}
#[derive(Serialize)]
struct BrowseResponse {
current: String,
parent: Option<String>,
entries: Vec<BrowseEntry>,
}
#[derive(Serialize)]
struct BrowseEntry {
name: String,
path: String,
}
async fn browse_directory(
Query(query): Query<BrowseQuery>,
) -> Result<Json<BrowseResponse>, (StatusCode, String)> {
let target = match &query.path {
Some(p) if !p.is_empty() => PathBuf::from(p),
_ => dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
};
let canonical = tokio::fs::canonicalize(&target)
.await
.map_err(|e| (StatusCode::NOT_FOUND, format!("Path not found: {e}")))?;
if !canonical.is_dir() {
return Err((StatusCode::BAD_REQUEST, "Path is not a directory".into()));
}
let parent = canonical.parent().map(|p| p.to_string_lossy().to_string());
let mut reader = tokio::fs::read_dir(&canonical).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Cannot read directory: {e}"),
)
})?;
let mut entries = Vec::new();
while let Some(entry) = reader.next_entry().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Read error: {e}"),
)
})? {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let metadata = match entry.metadata().await {
Ok(m) => m,
Err(_) => continue,
};
if !metadata.is_dir() {
continue;
}
let abs_path = entry.path().to_string_lossy().to_string();
entries.push(BrowseEntry {
name,
path: abs_path,
});
}
entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(Json(BrowseResponse {
current: canonical.to_string_lossy().to_string(),
parent,
entries,
}))
}