use std::path::{Path, PathBuf};
use std::{env, fs};
use serde::{Deserialize, Serialize};
use crate::git::{ProjectGroup, Repository, Worktree};
const MAX_RECENT_PROJECTS: usize = 20;
const RECENT_PROJECTS_FILE: &str = "recent-projects.json";
#[cfg(test)]
thread_local! {
static STATE_ROOT_OVERRIDE: std::cell::RefCell<Option<PathBuf>> =
const { std::cell::RefCell::new(None) };
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RecentEntry {
path: PathBuf,
common_dir: PathBuf,
}
pub(crate) fn record_recent_project(root: &Path) {
let Ok(repository) = Repository::discover(root) else {
return;
};
let Ok(common_dir) = repository.git_common_dir() else {
return;
};
let mut entries = read_entries();
entries.retain(|entry| entry.common_dir != common_dir);
entries.insert(
0,
RecentEntry {
path: repository.root().to_path_buf(),
common_dir,
},
);
entries.truncate(MAX_RECENT_PROJECTS);
write_entries(&entries);
}
pub(crate) fn forget_recent_project(common_dir: &Path) {
let mut entries = read_entries();
entries.retain(|entry| entry.common_dir != common_dir);
write_entries(&entries);
}
pub(crate) fn load_recent_projects(session_root: &Path) -> Vec<ProjectGroup> {
let mut groups = Vec::new();
let mut seen = Vec::new();
for entry in recent_entries_with_current(session_root) {
if seen.iter().any(|common| common == &entry.common_dir) {
continue;
}
let Some(repository) = open_repository(&entry) else {
continue;
};
let Ok(worktrees) = repository.worktrees_relative_to(session_root) else {
continue;
};
seen.push(entry.common_dir.clone());
groups.push(ProjectGroup {
name: project_name(&worktrees, &repository),
common_dir: entry.common_dir,
worktrees,
});
}
groups
}
fn recent_entries_with_current(session_root: &Path) -> Vec<RecentEntry> {
let mut entries = read_entries();
if let Ok(repository) = Repository::discover(session_root)
&& let Ok(common_dir) = repository.git_common_dir()
{
entries.retain(|entry| entry.common_dir != common_dir);
entries.insert(
0,
RecentEntry {
path: repository.root().to_path_buf(),
common_dir,
},
);
}
entries
}
fn open_repository(entry: &RecentEntry) -> Option<Repository> {
Repository::discover(&entry.path)
.ok()
.or_else(|| Repository::discover(&entry.common_dir).ok())
}
fn project_name(worktrees: &[Worktree], repository: &Repository) -> String {
worktrees
.first()
.and_then(|tree| tree.path.file_name())
.map_or_else(
|| repository.name(),
|name| name.to_string_lossy().into_owned(),
)
}
fn read_entries() -> Vec<RecentEntry> {
let Some(path) = recent_projects_path() else {
return Vec::new();
};
let Ok(data) = fs::read(&path) else {
return Vec::new();
};
serde_json::from_slice(&data).unwrap_or_default()
}
fn write_entries(entries: &[RecentEntry]) {
let Some(path) = recent_projects_path() else {
return;
};
if let Some(parent) = path.parent() {
drop(fs::create_dir_all(parent));
}
let Ok(data) = serde_json::to_vec_pretty(entries) else {
return;
};
let staging = path.with_extension("json.tmp");
if fs::write(&staging, data).is_ok() {
drop(fs::rename(staging, path));
}
}
fn recent_projects_path() -> Option<PathBuf> {
Some(state_root()?.join(RECENT_PROJECTS_FILE))
}
fn state_root() -> Option<PathBuf> {
#[cfg(test)]
{
let override_root = STATE_ROOT_OVERRIDE.with(|cell| cell.borrow().clone());
if override_root.is_some() {
return override_root;
}
}
if let Some(path) = env::var_os("QUINJET_STATE_DIR").filter(|path| !path.is_empty()) {
return Some(PathBuf::from(path));
}
#[cfg(windows)]
{
if let Some(path) = env::var_os("LOCALAPPDATA").filter(|path| !path.is_empty()) {
return Some(PathBuf::from(path).join("Quinjet").join("state"));
}
}
let home = env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.filter(|path| !path.is_empty())?;
let home = PathBuf::from(home);
Some(
env::var_os("XDG_STATE_HOME")
.filter(|path| !path.is_empty())
.map_or_else(|| home.join(".local/state"), PathBuf::from)
.join("quinjet"),
)
}
#[cfg(test)]
mod tests {
use std::process::Command;
use super::*;
use crate::git::support::same_path;
#[test]
fn records_one_project_per_common_directory() {
let state = tempfile::tempdir().unwrap();
drop(STATE_ROOT_OVERRIDE.with(|cell| cell.replace(Some(state.path().to_path_buf()))));
let repo = git_repo();
let linked_root = tempfile::tempdir().unwrap();
let linked = linked_root.path().join("topic");
let linked_display = linked.display().to_string();
run_git(
repo.path(),
&["worktree", "add", "-b", "topic", &linked_display],
);
record_recent_project(repo.path());
record_recent_project(&linked);
let entries = read_entries();
assert_eq!(entries.len(), 1);
let groups = load_recent_projects(&linked);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].worktrees.len(), 2);
assert!(
groups[0]
.worktrees
.iter()
.any(|tree| tree.current && same_path(&tree.path, &linked))
);
forget_recent_project(&entries[0].common_dir);
assert_eq!(read_entries(), Vec::<RecentEntry>::new());
drop(STATE_ROOT_OVERRIDE.with(|cell| cell.replace(None)));
}
fn git_repo() -> tempfile::TempDir {
let path = tempfile::tempdir().unwrap();
run_git(path.path(), &["init", "--initial-branch=main"]);
fs::write(path.path().join("README.md"), "test\n").unwrap();
run_git(path.path(), &["add", "README.md"]);
run_git(
path.path(),
&[
"-c",
"user.name=Quinjet Test",
"-c",
"user.email=quinjet@example.com",
"commit",
"--message=initial",
],
);
path
}
fn run_git(path: &Path, args: &[&str]) {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.env("LC_ALL", "C")
.output()
.unwrap();
assert!(
output.status.success(),
"git failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}