Файли
openidea/crates/ide-workspace/src/recent.rs
T
l0ng-ai 2760c91bde feat(welcome): group recent projects (#1465)
Welcome › Projects can put recent projects into named groups: Move to Group (other groups, New Group…, Remove from group) on a project, Rename / New / Remove Group on a group (removing keeps its projects). Groups are listed first, fold with click, Enter or ←/→, and searching lists matches without groups. Groups live in recent-groups.json beside recent.json; grouped projects are exempt from the 30-project cap and leave their group when removed from recent projects.

Fixes #1420
2026-11-05 13:24:15 +08:00

265 рядки
9.8 KiB
Rust
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RecentProject {
pub path: ide_core::IpcPath,
pub name: String,
pub opened_at_ms: f64,
/// Current git branch when last opened, shown on the welcome screen.
pub branch: Option<String>,
}
pub const MAX_RECENT_PROJECTS: usize = 30;
/// A named group of recent projects on the welcome screen (New Project Group…, Move to Group).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RecentProjectGroup {
pub name: String,
/// Its projects, in the order they were added.
pub paths: Vec<ide_core::IpcPath>,
#[serde(default = "expanded")]
pub expanded: bool,
}
fn expanded() -> bool {
true
}
/// Persisted at `<app-config>/recent.json`, newest first; groups at `<app-config>/recent-groups.json`.
pub struct RecentProjects {
file: PathBuf,
items: Mutex<Vec<RecentProject>>,
groups_file: PathBuf,
groups: Mutex<Vec<RecentProjectGroup>>,
}
fn read_json<T: serde::de::DeserializeOwned>(file: &Path) -> Option<T> {
match std::fs::read(file) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| tracing::warn!("{} is invalid, ignoring: {e}", file.display()))
.ok(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
tracing::warn!("cannot read {}: {e}", file.display());
None
}
}
}
fn write_json<T: Serialize + ?Sized>(file: &Path, value: &T) -> IdeResult<()> {
if let Some(dir) = file.parent() {
std::fs::create_dir_all(dir)?;
}
let json = serde_json::to_vec_pretty(value).map_err(|e| IdeError::internal(e.to_string()))?;
ide_vfs::write_atomic(file, &json)?;
Ok(())
}
/// Each path in one group at most (the first keeps it), group names trimmed and unique.
fn normalize(groups: Vec<RecentProjectGroup>) -> Vec<RecentProjectGroup> {
let mut paths = std::collections::HashSet::new();
let mut names = std::collections::HashSet::new();
groups
.into_iter()
.filter_map(|mut g| {
g.name = g.name.trim().to_owned();
if g.name.is_empty() || !names.insert(g.name.clone()) {
return None;
}
g.paths.retain(|p| paths.insert(p.clone()));
Some(g)
})
.collect()
}
/// Keeps the newest [`MAX_RECENT_PROJECTS`] projects outside groups; grouped ones stay.
fn cap(items: &mut Vec<RecentProject>, groups: &[RecentProjectGroup]) {
let mut ungrouped = 0;
items.retain(|p| {
if groups.iter().any(|g| g.paths.contains(&p.path)) {
return true;
}
ungrouped += 1;
ungrouped <= MAX_RECENT_PROJECTS
});
}
fn now_ms() -> f64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
impl RecentProjects {
pub fn load(config_dir: &Path) -> Self {
let file = config_dir.join("recent.json");
let groups_file = config_dir.join("recent-groups.json");
let mut items: Vec<RecentProject> = read_json(&file).unwrap_or_default();
let mut groups = normalize(read_json(&groups_file).unwrap_or_default());
items.sort_by(|a, b| b.opened_at_ms.total_cmp(&a.opened_at_ms));
let mut seen = std::collections::HashSet::new();
items.retain(|p| seen.insert(p.path.clone()));
cap(&mut items, &groups);
// A group lists only projects that are still recent.
for g in &mut groups {
g.paths.retain(|p| items.iter().any(|i| i.path == *p));
}
Self { file, items: Mutex::new(items), groups_file, groups: Mutex::new(groups) }
}
pub fn list(&self) -> Vec<RecentProject> {
self.items.lock().clone()
}
pub fn groups(&self) -> Vec<RecentProjectGroup> {
self.groups.lock().clone()
}
/// Replaces the groups (paths that are not recent projects are dropped).
pub fn set_groups(&self, groups: Vec<RecentProjectGroup>) -> IdeResult<Vec<RecentProjectGroup>> {
let items = self.items.lock();
let mut groups = normalize(groups);
for g in &mut groups {
g.paths.retain(|p| items.iter().any(|i| i.path == *p));
}
write_json(&self.groups_file, &groups)?;
*self.groups.lock() = groups.clone();
Ok(groups)
}
/// Moves (or adds) `path` to the top of the list.
pub fn touch(&self, path: &Path, branch: Option<String>) -> IdeResult<()> {
let mut items = self.items.lock();
items.retain(|p| p.path != path);
let name =
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string());
items.insert(0, RecentProject { path: path.into(), name, opened_at_ms: now_ms(), branch });
cap(&mut items, &self.groups.lock());
self.persist(&items)
}
/// Shows `path` as `name` (File › Rename Project…); `None` restores its folder name.
pub fn rename(&self, path: &Path, name: Option<&str>) -> IdeResult<()> {
let mut items = self.items.lock();
let Some(item) = items.iter_mut().find(|p| p.path == path) else { return Ok(()) };
let name = name.map(str::to_owned).unwrap_or_else(|| {
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string())
});
if item.name == name {
return Ok(());
}
item.name = name;
self.persist(&items)
}
pub fn remove(&self, path: &Path) -> IdeResult<()> {
let mut items = self.items.lock();
let before = items.len();
items.retain(|p| p.path != path);
if items.len() == before {
return Ok(());
}
self.persist(&items)?;
let mut groups = self.groups.lock();
if groups.iter().any(|g| g.paths.iter().any(|p| p == path)) {
for g in groups.iter_mut() {
g.paths.retain(|p| p != path);
}
write_json(&self.groups_file, &*groups)?;
}
Ok(())
}
pub fn paths(&self) -> Vec<PathBuf> {
self.list().into_iter().map(|p| p.path.into_path_buf()).collect()
}
fn persist(&self, items: &[RecentProject]) -> IdeResult<()> {
write_json(&self.file, items)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn newest_first_capped_and_persisted() {
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join("config");
let recent = RecentProjects::load(&cfg);
assert!(recent.list().is_empty());
for i in 0..35 {
recent.touch(&PathBuf::from(format!("/p/proj{i}")), None).unwrap();
}
recent.touch(Path::new("/p/proj3"), Some("main".into())).unwrap();
let list = recent.list();
assert_eq!(list.len(), MAX_RECENT_PROJECTS);
assert_eq!(list[0].path, PathBuf::from("/p/proj3"));
assert_eq!(list[0].name, "proj3");
assert_eq!(list[0].branch.as_deref(), Some("main"));
assert_eq!(list[1].path, PathBuf::from("/p/proj34"));
recent.remove(Path::new("/p/proj34")).unwrap();
let reloaded = RecentProjects::load(&cfg);
assert_eq!(reloaded.paths(), recent.paths());
assert_eq!(reloaded.list().len(), MAX_RECENT_PROJECTS - 1);
assert!(!reloaded.paths().contains(&PathBuf::from("/p/proj34")));
std::fs::write(cfg.join("recent.json"), "not json").unwrap();
assert!(RecentProjects::load(&cfg).list().is_empty());
}
#[test]
fn groups_keep_their_projects_and_survive_a_reload() {
let tmp = tempfile::tempdir().unwrap();
let recent = RecentProjects::load(tmp.path());
for i in 0..3 {
recent.touch(&PathBuf::from(format!("/p/proj{i}")), None).unwrap();
}
let group = |name: &str, paths: &[&str]| RecentProjectGroup {
name: name.into(),
paths: paths.iter().map(|p| PathBuf::from(p).into()).collect(),
expanded: true,
};
let saved = recent
.set_groups(vec![
group(" Work ", &["/p/proj0", "/p/gone"]),
group("Work", &["/p/proj1"]),
group("Oss", &["/p/proj0", "/p/proj2"]),
group(" ", &[]),
])
.unwrap();
// Trimmed, unique names; each project in one group; only recent projects.
assert_eq!(saved, vec![group("Work", &["/p/proj0"]), group("Oss", &["/p/proj2"])]);
assert_eq!(RecentProjects::load(tmp.path()).groups(), saved);
// A grouped project outlives the cap; removing it from recent projects ungroups it.
for i in 3..40 {
recent.touch(&PathBuf::from(format!("/p/proj{i}")), None).unwrap();
}
let paths = recent.paths();
assert_eq!(paths.len(), MAX_RECENT_PROJECTS + 2);
assert!(paths.contains(&PathBuf::from("/p/proj0")) && paths.contains(&PathBuf::from("/p/proj2")));
recent.remove(Path::new("/p/proj2")).unwrap();
assert_eq!(RecentProjects::load(tmp.path()).groups(), vec![group("Work", &["/p/proj0"]), group("Oss", &[])]);
}
#[test]
fn rename_shows_a_custom_name_until_reset() {
let tmp = tempfile::tempdir().unwrap();
let recent = RecentProjects::load(tmp.path());
recent.touch(Path::new("/p/app"), None).unwrap();
recent.rename(Path::new("/p/app"), Some("Shop Backend")).unwrap();
recent.rename(Path::new("/p/unknown"), Some("x")).unwrap();
assert_eq!(RecentProjects::load(tmp.path()).list()[0].name, "Shop Backend");
recent.rename(Path::new("/p/app"), None).unwrap();
assert_eq!(recent.list()[0].name, "app");
assert_eq!(recent.list().len(), 1);
}
}