дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
1 джерело
a5fb380960
коміт
2760c91bde
12 files changed
+553
-51
No files matched your search
@@ -217,6 +217,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "lsp_resend_diagnostics"
|
||||
| "recent_projects"
|
||||
| "remove_recent_project"
|
||||
| "recent_project_groups"
|
||||
| "set_recent_project_groups"
|
||||
| "open_project"
|
||||
| "close_project"
|
||||
| "project_current"
|
||||
|
||||
@@ -25,7 +25,7 @@ pub use editorconfig::{EditorConfig, EditorConfigProps};
|
||||
pub use file_defaults::FileDefaults;
|
||||
pub use file_moves::{FileMove, FileMoves, UndoOutcome};
|
||||
pub use history::{DirectoryHistory, HistoryEntry, HistoryKind, LocalHistory, Retention};
|
||||
pub use recent::{RecentProject, RecentProjects};
|
||||
pub use recent::{RecentProject, RecentProjectGroup, RecentProjects};
|
||||
pub use roots::{ProjectRoots, RootKind, SourceFolder};
|
||||
pub use settings::{SettingsScope, SettingsStore};
|
||||
|
||||
|
||||
@@ -16,10 +16,78 @@ pub struct RecentProject {
|
||||
|
||||
pub const MAX_RECENT_PROJECTS: usize = 30;
|
||||
|
||||
/// Persisted at `<app-config>/recent.json`, newest first.
|
||||
/// 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 {
|
||||
@@ -29,28 +97,40 @@ fn now_ms() -> f64 {
|
||||
impl RecentProjects {
|
||||
pub fn load(config_dir: &Path) -> Self {
|
||||
let file = config_dir.join("recent.json");
|
||||
let mut items: Vec<RecentProject> = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("{} is invalid, ignoring: {e}", file.display());
|
||||
Vec::new()
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot read {}: {e}", file.display());
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
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()));
|
||||
items.truncate(MAX_RECENT_PROJECTS);
|
||||
Self { file, items: Mutex::new(items) }
|
||||
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();
|
||||
@@ -58,7 +138,7 @@ impl RecentProjects {
|
||||
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 });
|
||||
items.truncate(MAX_RECENT_PROJECTS);
|
||||
cap(&mut items, &self.groups.lock());
|
||||
self.persist(&items)
|
||||
}
|
||||
|
||||
@@ -83,7 +163,15 @@ impl RecentProjects {
|
||||
if items.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
self.persist(&items)
|
||||
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> {
|
||||
@@ -91,12 +179,7 @@ impl RecentProjects {
|
||||
}
|
||||
|
||||
fn persist(&self, items: &[RecentProject]) -> IdeResult<()> {
|
||||
if let Some(dir) = self.file.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(items).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(&self.file, &json)?;
|
||||
Ok(())
|
||||
write_json(&self.file, items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +214,41 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -2,8 +2,8 @@ use crate::state::AppState;
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{EventSink, IdeError, IdeResult, RequestId, TaskId};
|
||||
use ide_language::LanguageInfo;
|
||||
use ide_workspace::RecentProject;
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
use ide_workspace::{RecentProject, RecentProjectGroup};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
@@ -49,6 +49,24 @@ pub fn remove_recent_project(state: State<'_, AppState>, path: PathBuf) -> IdeRe
|
||||
state.recent.remove(&path)
|
||||
}
|
||||
|
||||
/// The welcome screen's project groups.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn recent_project_groups(state: State<'_, AppState>) -> Vec<RecentProjectGroup> {
|
||||
state.recent.groups()
|
||||
}
|
||||
|
||||
/// Replaces the project groups; returns them as saved (names trimmed and unique, each
|
||||
/// project in one group, only recent projects).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn set_recent_project_groups(
|
||||
state: State<'_, AppState>,
|
||||
groups: Vec<RecentProjectGroup>,
|
||||
) -> IdeResult<Vec<RecentProjectGroup>> {
|
||||
state.recent.set_groups(groups)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInfo> {
|
||||
|
||||
@@ -65,6 +65,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
app::app_info,
|
||||
app::recent_projects,
|
||||
app::remove_recent_project,
|
||||
app::recent_project_groups,
|
||||
app::set_recent_project_groups,
|
||||
app::open_project,
|
||||
app::project_trust_status,
|
||||
app::project_trust_list,
|
||||
|
||||
@@ -10,6 +10,13 @@ export const commands = {
|
||||
appInfo: () => __TAURI_INVOKE<AppInfo>("app_info"),
|
||||
recentProjects: () => __TAURI_INVOKE<RecentProject[]>("recent_projects"),
|
||||
removeRecentProject: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remove_recent_project", { path })),
|
||||
/** The welcome screen's project groups. */
|
||||
recentProjectGroups: () => __TAURI_INVOKE<RecentProjectGroup[]>("recent_project_groups"),
|
||||
/**
|
||||
* Replaces the project groups; returns them as saved (names trimmed and unique, each
|
||||
* project in one group, only recent projects).
|
||||
*/
|
||||
setRecentProjectGroups: (groups: RecentProjectGroup[]) => typedError<RecentProjectGroup[], IdeError>(__TAURI_INVOKE("set_recent_project_groups", { groups })),
|
||||
openProject: (path: string) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("open_project", { path })),
|
||||
projectTrustStatus: (path: string) => typedError<TrustStatus, IdeError>(__TAURI_INVOKE("project_trust_status", { path })),
|
||||
projectTrustList: () => typedError<TrustDecisions, IdeError>(__TAURI_INVOKE("project_trust_list")),
|
||||
@@ -5966,6 +5973,14 @@ export type RecentProject = {
|
||||
branch: string | null,
|
||||
};
|
||||
|
||||
/** A named group of recent projects on the welcome screen (New Project Group…, Move to Group). */
|
||||
export type RecentProjectGroup = {
|
||||
name: string,
|
||||
/** Its projects, in the order they were added. */
|
||||
paths: IpcPath[],
|
||||
expanded?: boolean,
|
||||
};
|
||||
|
||||
export type RefactoringProblem = {
|
||||
severity: ProblemSeverity,
|
||||
message: string,
|
||||
|
||||
@@ -19,8 +19,9 @@ import {
|
||||
showContextMenu,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { askRemoveRecent, ProjectBadge, recentMenu } from "./RecentList";
|
||||
import { filterRecent, openRecent, useRecent, type RecentMatch } from "./recent";
|
||||
import { askRemoveRecent, groupMenu, ProjectBadge, recentMenu } from "./RecentList";
|
||||
import { openRecent, recentRows, setGroupExpanded, useRecent, type RecentMatch, type RecentRow } from "./recent";
|
||||
import type { RecentProjectGroup } from "@/platform/ipc";
|
||||
import styles from "./Welcome.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -113,16 +114,78 @@ function Landing() {
|
||||
);
|
||||
}
|
||||
|
||||
/** A project group: click or Enter folds it, its menu renames or removes it. */
|
||||
function GroupRow({
|
||||
group,
|
||||
count,
|
||||
id,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
group: RecentProjectGroup;
|
||||
count: number;
|
||||
id: string;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (selected) ref.current?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [selected]);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-expanded={group.expanded}
|
||||
className={clsx(styles.project, styles.group, selected && styles.projectSelected)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onSelect();
|
||||
void setGroupExpanded(group.name, !group.expanded);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
showContextMenu(e, groupMenu(group));
|
||||
}}
|
||||
>
|
||||
<Icon name={group.expanded ? "chevron-down" : "chevron-right"} size={16} />
|
||||
<Icon name="folder" size={16} />
|
||||
<span className={styles.groupName}>{group.name}</span>
|
||||
<span className={styles.groupCount}>{count}</span>
|
||||
<IconButton
|
||||
icon="more"
|
||||
title="Group actions"
|
||||
aria-haspopup="menu"
|
||||
iconSize={16}
|
||||
className={styles.projectMore}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu({ x: r.left, y: r.bottom + 2 }, groupMenu(group));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectRow({
|
||||
m,
|
||||
id,
|
||||
selected,
|
||||
nested,
|
||||
onSelect,
|
||||
onOpen,
|
||||
}: {
|
||||
m: RecentMatch;
|
||||
id: string;
|
||||
selected: boolean;
|
||||
/** Listed under a group. */
|
||||
nested?: boolean;
|
||||
onSelect: () => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
@@ -143,7 +206,7 @@ function ProjectRow({
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
title={m.project.path}
|
||||
className={clsx(styles.project, selected && styles.projectSelected)}
|
||||
className={clsx(styles.project, nested && styles.projectNested, selected && styles.projectSelected)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onSelect();
|
||||
@@ -195,10 +258,10 @@ function ProjectRow({
|
||||
}
|
||||
|
||||
/** Keys shared by the search field and the list. Returns true when handled. */
|
||||
export function handleListKey(
|
||||
export function handleListKey<T>(
|
||||
e: Pick<KeyboardEvent, "key" | "metaKey" | "shiftKey">,
|
||||
ctx: { items: readonly RecentMatch[]; current: number; caretAtEnd: boolean },
|
||||
): { kind: "move"; index: number } | { kind: "open" | "remove" | "menu"; match: RecentMatch } | null {
|
||||
ctx: { items: readonly T[]; current: number; caretAtEnd: boolean },
|
||||
): { kind: "move"; index: number } | { kind: "open" | "remove" | "menu"; match: T } | null {
|
||||
const m = ctx.items[ctx.current];
|
||||
if (e.key === "Enter") return m ? { kind: "open", match: m } : null;
|
||||
if (m && ((e.key === "Delete" && ctx.caretAtEnd) || (e.key === "Backspace" && e.metaKey)))
|
||||
@@ -210,13 +273,14 @@ export function handleListKey(
|
||||
|
||||
export function ProjectsPage() {
|
||||
const projects = useRecent((s) => s.projects);
|
||||
const groups = useRecent((s) => s.groups);
|
||||
const loaded = useRecent((s) => s.loaded);
|
||||
const home = useRecent((s) => s.home);
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const baseId = useId();
|
||||
const items = useMemo(() => filterRecent(projects, query, home), [projects, query, home]);
|
||||
const items = useMemo(() => recentRows(projects, groups, query, home), [projects, groups, query, home]);
|
||||
const current = Math.max(0, Math.min(active, items.length - 1));
|
||||
|
||||
if (!loaded) return null;
|
||||
@@ -228,6 +292,13 @@ export function ProjectsPage() {
|
||||
const input = e.target instanceof HTMLInputElement ? e.target : null;
|
||||
const caretAtEnd =
|
||||
!input || (input.selectionStart === input.value.length && input.selectionEnd === input.value.length);
|
||||
const row = items[current];
|
||||
// A group folds with ← / → (and Enter, below), like a tree node.
|
||||
if (row?.kind === "group" && !input && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
||||
e.preventDefault();
|
||||
void setGroupExpanded(row.group.name, e.key === "ArrowRight");
|
||||
return;
|
||||
}
|
||||
const r = handleListKey(e, { items, current, caretAtEnd });
|
||||
if (!r) {
|
||||
// Typing in the list goes to the search field.
|
||||
@@ -240,12 +311,24 @@ export function ProjectsPage() {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (r.kind === "move") setActive(r.index);
|
||||
else if (r.kind === "open") void openRecent(r.match.project);
|
||||
else if (r.kind === "remove") void askRemoveRecent(r.match.project);
|
||||
if (r.kind === "move") {
|
||||
setActive(r.index);
|
||||
return;
|
||||
}
|
||||
const match: RecentRow = r.match;
|
||||
if (match.kind === "group") {
|
||||
if (r.kind === "open") void setGroupExpanded(match.group.name, !match.group.expanded);
|
||||
else if (r.kind === "menu") {
|
||||
const el = document.getElementById(optionId(current))?.getBoundingClientRect();
|
||||
showContextMenu({ x: (el?.right ?? 0) - 40, y: (el?.top ?? 0) + 24 }, groupMenu(match.group));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (r.kind === "open") void openRecent(match.m.project);
|
||||
else if (r.kind === "remove") void askRemoveRecent(match.m.project);
|
||||
else {
|
||||
const el = document.getElementById(optionId(current))?.getBoundingClientRect();
|
||||
showContextMenu({ x: (el?.right ?? 0) - 40, y: (el?.top ?? 0) + 24 }, recentMenu(r.match.project));
|
||||
showContextMenu({ x: (el?.right ?? 0) - 40, y: (el?.top ?? 0) + 24 }, recentMenu(match.m.project));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -279,16 +362,28 @@ export function ProjectsPage() {
|
||||
className={styles.projectList}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{items.map((m, i) => (
|
||||
<ProjectRow
|
||||
key={m.project.path}
|
||||
m={m}
|
||||
id={optionId(i)}
|
||||
selected={i === current}
|
||||
onSelect={() => setActive(i)}
|
||||
onOpen={() => void openRecent(m.project)}
|
||||
/>
|
||||
))}
|
||||
{items.map((row, i) =>
|
||||
row.kind === "group" ? (
|
||||
<GroupRow
|
||||
key={`group:${row.group.name}`}
|
||||
group={row.group}
|
||||
count={row.count}
|
||||
id={optionId(i)}
|
||||
selected={i === current}
|
||||
onSelect={() => setActive(i)}
|
||||
/>
|
||||
) : (
|
||||
<ProjectRow
|
||||
key={row.m.project.path}
|
||||
m={row.m}
|
||||
id={optionId(i)}
|
||||
selected={i === current}
|
||||
nested={row.group !== null}
|
||||
onSelect={() => setActive(i)}
|
||||
onOpen={() => void openRecent(row.m.project)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{items.length === 0 && (
|
||||
<div className={styles.noMatches}>
|
||||
<T>No matching projects</T>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { call, commands, type RecentProject } from "@/platform/ipc";
|
||||
import { call, commands, type RecentProject, type RecentProjectGroup } from "@/platform/ipc";
|
||||
import { parseRemoteLocation } from "@/platform/remote";
|
||||
import {
|
||||
HighlightedText,
|
||||
@@ -13,9 +13,22 @@ import {
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { promptInput } from "@/workbench/inputDialogs";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { projectColorVars, projectInitials } from "@/workbench/projectColor";
|
||||
import { filterRecent, openRecent, removeRecent, useRecent, type RecentMatch } from "./recent";
|
||||
import {
|
||||
createGroup,
|
||||
filterRecent,
|
||||
groupNameError,
|
||||
groupOf,
|
||||
moveToGroup,
|
||||
openRecent,
|
||||
removeGroup,
|
||||
removeRecent,
|
||||
renameGroup,
|
||||
useRecent,
|
||||
type RecentMatch,
|
||||
} from "./recent";
|
||||
import styles from "./Welcome.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -38,6 +51,63 @@ export function ProjectBadge({ name, className }: { name: string; className?: st
|
||||
);
|
||||
}
|
||||
|
||||
/** New Project Group…: asks for a name; with `path`, that project moves into the new group. */
|
||||
export async function askNewGroup(path?: string) {
|
||||
const name = await promptInput({
|
||||
title: "New Project Group",
|
||||
label: "Group name:",
|
||||
okLabel: "Create",
|
||||
validate: (v) => groupNameError(v),
|
||||
});
|
||||
if (name !== null) await createGroup(name, path);
|
||||
}
|
||||
|
||||
/** Move to Group: the other groups, a new one, or out of its group. */
|
||||
function moveToGroupMenu(project: RecentProject): MenuItem {
|
||||
const groups = useRecent.getState().groups;
|
||||
const current = groupOf(project.path, groups)?.name ?? null;
|
||||
const others = groups.filter((g) => g.name !== current);
|
||||
const submenu: MenuItem[] = [
|
||||
...others.map((g): MenuItem => ({
|
||||
label: g.name,
|
||||
verbatim: true,
|
||||
run: () => void moveToGroup(project.path, g.name),
|
||||
})),
|
||||
...(others.length ? [{ kind: "separator" } as const] : []),
|
||||
{ label: "New Group…", run: () => void askNewGroup(project.path) },
|
||||
...(current
|
||||
? [{ label: tx`Remove from '${current}'`, verbatim: true, run: () => void moveToGroup(project.path, null) }]
|
||||
: []),
|
||||
];
|
||||
return { label: "Move to Group", icon: "folder", submenu };
|
||||
}
|
||||
|
||||
/** A project group's actions. */
|
||||
export function groupMenu(group: RecentProjectGroup): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: "Rename Group…",
|
||||
icon: "edit",
|
||||
run: () =>
|
||||
void promptInput({
|
||||
title: "Rename Project Group",
|
||||
label: "Group name:",
|
||||
initial: group.name,
|
||||
okLabel: "Rename",
|
||||
validate: (v) => groupNameError(v, group.name),
|
||||
}).then((name) => (name === null ? undefined : renameGroup(group.name, name))),
|
||||
},
|
||||
{ label: "New Project Group…", icon: "plus", run: () => void askNewGroup() },
|
||||
{ kind: "separator" },
|
||||
{
|
||||
label: "Remove Group",
|
||||
icon: "close",
|
||||
danger: true,
|
||||
run: () => void removeGroup(group.name),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function recentMenu(project: RecentProject, onOpened?: () => void): MenuItem[] {
|
||||
// A remote project has no local folder.
|
||||
const remote = parseRemoteLocation(project.path);
|
||||
@@ -73,6 +143,8 @@ export function recentMenu(project: RecentProject, onOpened?: () => void): MenuI
|
||||
run: () => void navigator.clipboard?.writeText(project.path).catch(() => undefined),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
// Groups hold local projects (remote ones live in this window's own list).
|
||||
...(remote ? [] : [moveToGroupMenu(project), { kind: "separator" } as const]),
|
||||
{
|
||||
id: "remove",
|
||||
label: "Remove from Recent Projects…",
|
||||
|
||||
@@ -172,6 +172,26 @@
|
||||
.projectBadge {
|
||||
margin-top: 1px;
|
||||
}
|
||||
/* A project group: one line, folds its projects. */
|
||||
.group {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
--icon-color: var(--fg-muted);
|
||||
}
|
||||
.groupName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--fg-strong);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.groupCount {
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.projectNested {
|
||||
padding-left: 32px;
|
||||
}
|
||||
.projectText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { RecentProject } from "@/bindings";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { filterRecent, tildify } from "./recent";
|
||||
import {
|
||||
createGroup,
|
||||
filterRecent,
|
||||
groupNameError,
|
||||
moveToGroup,
|
||||
recentRows,
|
||||
removeGroup,
|
||||
renameGroup,
|
||||
tildify,
|
||||
useRecent,
|
||||
} from "./recent";
|
||||
import { lastSessionProject, reopenEnabled, trackSession } from "./session";
|
||||
|
||||
const p = (name: string, path: string, branch: string | null = null): RecentProject => ({
|
||||
@@ -77,3 +87,45 @@ describe("session restore", () => {
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("project groups", () => {
|
||||
const rows = (query = "") =>
|
||||
recentRows(projects, useRecent.getState().groups, query, "/Users/me").map((r) =>
|
||||
r.kind === "group" ? `[${r.group.name} ${r.count}]` : `${r.group ? " " : ""}${r.m.project.name}`,
|
||||
);
|
||||
afterEach(() => useRecent.setState({ groups: [] }));
|
||||
|
||||
it("lists groups first with their projects, then the rest; a search lists matches only", async () => {
|
||||
expect(rows()).toEqual(["acme-api", "openidea-ide", "tokio", "notes"]);
|
||||
await createGroup(" Work ", "/Users/me/dev/acme");
|
||||
await moveToGroup("/Users/me/dev/openidea", "Work");
|
||||
await createGroup("Oss", "/Users/me/oss/tokio");
|
||||
expect(rows()).toEqual(["[Work 2]", " acme-api", " openidea-ide", "[Oss 1]", " tokio", "notes"]);
|
||||
expect(rows("tok")).toEqual(["tokio"]);
|
||||
|
||||
useRecent.setState((s) => ({ groups: s.groups.map((g) => ({ ...g, expanded: g.name !== "Work" })) }));
|
||||
expect(rows()).toEqual(["[Work 2]", "[Oss 1]", " tokio", "notes"]);
|
||||
});
|
||||
|
||||
it("moves a project between groups, renames and removes groups without dropping projects", async () => {
|
||||
await createGroup("Work", "/Users/me/dev/acme");
|
||||
await createGroup("Oss");
|
||||
expect(groupNameError("work ")).toBeNull();
|
||||
expect(groupNameError(" Work")).toBe("A group named 'Work' already exists");
|
||||
expect(groupNameError("Work", "Work")).toBeNull();
|
||||
expect(groupNameError(" ")).toBe("Enter a group name");
|
||||
// A second group of the same name is not created.
|
||||
await createGroup("Work", "/Users/me/oss/tokio");
|
||||
expect(useRecent.getState().groups.map((g) => g.name)).toEqual(["Work", "Oss"]);
|
||||
|
||||
await moveToGroup("/Users/me/dev/acme", "Oss");
|
||||
expect(rows()).toEqual(["[Work 0]", "[Oss 1]", " acme-api", "openidea-ide", "tokio", "notes"]);
|
||||
await renameGroup("Oss", "Open source");
|
||||
await moveToGroup("/Users/me/oss/tokio", "Open source");
|
||||
await removeGroup("Work");
|
||||
expect(rows()).toEqual(["[Open source 2]", " acme-api", " tokio", "openidea-ide", "notes"]);
|
||||
await moveToGroup("/Users/me/dev/acme", null);
|
||||
await removeGroup("Open source");
|
||||
expect(rows()).toEqual(["acme-api", "openidea-ide", "tokio", "notes"]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Recent projects: loading, filtering, opening and removal. */
|
||||
import { create } from "zustand";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { commands, IpcError, isCancelled, isTauri, type RecentProject } from "@/platform/ipc";
|
||||
import { commands, IpcError, isCancelled, isTauri, type RecentProject, type RecentProjectGroup } from "@/platform/ipc";
|
||||
import { openProject, useProject } from "@/platform/project";
|
||||
import { connectRemote, forgetRemote, parseRemoteLocation, recentRemotes, remoteLocation } from "@/platform/remote";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -11,6 +11,8 @@ import { openRemoteProject } from "@/workbench/projectWindow";
|
||||
|
||||
interface RecentState {
|
||||
projects: RecentProject[];
|
||||
/** Project groups (New Project Group…), shown above the ungrouped projects. */
|
||||
groups: RecentProjectGroup[];
|
||||
loaded: boolean;
|
||||
home: string | null;
|
||||
version: string | null;
|
||||
@@ -20,6 +22,7 @@ interface RecentState {
|
||||
|
||||
export const useRecent = create<RecentState>(() => ({
|
||||
projects: [],
|
||||
groups: [],
|
||||
loaded: false,
|
||||
home: null,
|
||||
version: null,
|
||||
@@ -43,13 +46,15 @@ export async function loadRecent() {
|
||||
useRecent.setState({ projects: SAMPLE, loaded: true, home: "/Users/dev", version: "0.1.0-dev" });
|
||||
return;
|
||||
}
|
||||
const [projects, info, home] = await Promise.all([
|
||||
const [projects, groups, info, home] = await Promise.all([
|
||||
commands.recentProjects().catch(() => [] as RecentProject[]),
|
||||
commands.recentProjectGroups().catch(() => [] as RecentProjectGroup[]),
|
||||
commands.appInfo().catch(() => null),
|
||||
import("@tauri-apps/api/path").then((m) => m.homeDir()).catch(() => null),
|
||||
]);
|
||||
useRecent.setState({
|
||||
projects: withRemotes(projects),
|
||||
groups,
|
||||
loaded: true,
|
||||
version: info?.version ?? useRecent.getState().version,
|
||||
home: home ? home.replace(/\/+$/, "") : useRecent.getState().home,
|
||||
@@ -77,7 +82,10 @@ export function remoteDisplay(location: string): string | null {
|
||||
}
|
||||
|
||||
export async function removeRecent(path: string) {
|
||||
useRecent.setState((s) => ({ projects: s.projects.filter((p) => p.path !== path) }));
|
||||
useRecent.setState((s) => ({
|
||||
projects: s.projects.filter((p) => p.path !== path),
|
||||
groups: s.groups.map((g) => (g.paths.includes(path) ? { ...g, paths: g.paths.filter((p) => p !== path) } : g)),
|
||||
}));
|
||||
const remote = parseRemoteLocation(path);
|
||||
if (remote) {
|
||||
forgetRemote(remote.sshConfig, remote.path);
|
||||
@@ -205,3 +213,90 @@ function isCompactMatch(indices: number[]): boolean {
|
||||
if (indices.length < 2) return true;
|
||||
return indices[indices.length - 1]! - indices[0]! < indices.length * 6;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project groups
|
||||
|
||||
/** The group `path` belongs to, if any. */
|
||||
export function groupOf(path: string, groups: readonly RecentProjectGroup[]): RecentProjectGroup | undefined {
|
||||
return groups.find((g) => g.paths.includes(path));
|
||||
}
|
||||
|
||||
/** Saves `next` (shown at once; the backend's normalised copy replaces it). */
|
||||
async function saveGroups(next: RecentProjectGroup[]) {
|
||||
useRecent.setState({ groups: next });
|
||||
if (!isTauri()) return;
|
||||
const r = await commands.setRecentProjectGroups(next);
|
||||
if (r.status === "ok") useRecent.setState({ groups: r.data });
|
||||
else notifications().notify({ level: "error", title: "Cannot save project groups", body: r.error.message });
|
||||
}
|
||||
|
||||
const groups = () => useRecent.getState().groups;
|
||||
|
||||
/** Why `name` can't name a new group (or group `current` renamed), or null. */
|
||||
export function groupNameError(value: string, current?: string): string | null {
|
||||
const name = value.trim();
|
||||
if (!name) return tx("Enter a group name");
|
||||
return name !== current && groups().some((g) => g.name === name) ? tx`A group named '${name}' already exists` : null;
|
||||
}
|
||||
|
||||
/** New Project Group…; with `path`, the project moves into it. */
|
||||
export function createGroup(name: string, path?: string) {
|
||||
const n = name.trim();
|
||||
if (groupNameError(n)) return Promise.resolve();
|
||||
const rest = groups().map((g) => (path ? { ...g, paths: g.paths.filter((p) => p !== path) } : g));
|
||||
return saveGroups([...rest, { name: n, paths: path ? [path] : [], expanded: true }]);
|
||||
}
|
||||
|
||||
export function renameGroup(name: string, to: string) {
|
||||
const n = to.trim();
|
||||
if (n === name || groupNameError(n, name)) return Promise.resolve();
|
||||
return saveGroups(groups().map((g) => (g.name === name ? { ...g, name: n } : g)));
|
||||
}
|
||||
|
||||
/** Removes the group; its projects stay in the list, ungrouped. */
|
||||
export function removeGroup(name: string) {
|
||||
return saveGroups(groups().filter((g) => g.name !== name));
|
||||
}
|
||||
|
||||
/** Moves `path` into group `name`, or out of every group with `null`. */
|
||||
export function moveToGroup(path: string, name: string | null) {
|
||||
return saveGroups(
|
||||
groups().map((g) => {
|
||||
const paths = g.paths.filter((p) => p !== path);
|
||||
return { ...g, paths: g.name === name ? [...paths, path] : paths };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function setGroupExpanded(name: string, expanded: boolean) {
|
||||
return saveGroups(groups().map((g) => (g.name === name ? { ...g, expanded } : g)));
|
||||
}
|
||||
|
||||
export type RecentRow =
|
||||
| { kind: "group"; group: RecentProjectGroup; count: number }
|
||||
| { kind: "project"; m: RecentMatch; group: string | null };
|
||||
|
||||
/**
|
||||
* The rows of the list: without a query, groups first (their projects under the expanded
|
||||
* ones, most recent first), then the ungrouped projects; while searching, the matches only.
|
||||
*/
|
||||
export function recentRows(
|
||||
projects: readonly RecentProject[],
|
||||
groupList: readonly RecentProjectGroup[],
|
||||
query: string,
|
||||
home?: string | null,
|
||||
): RecentRow[] {
|
||||
const matches = filterRecent(projects, query, home);
|
||||
if (query.trim() || groupList.length === 0) return matches.map((m) => ({ kind: "project", m, group: null }));
|
||||
const rows: RecentRow[] = [];
|
||||
const grouped = new Set<string>();
|
||||
for (const group of groupList) {
|
||||
const members = matches.filter((m) => group.paths.includes(m.project.path));
|
||||
members.forEach((m) => grouped.add(m.project.path));
|
||||
rows.push({ kind: "group", group, count: members.length });
|
||||
if (group.expanded) rows.push(...members.map((m): RecentRow => ({ kind: "project", m, group: group.name })));
|
||||
}
|
||||
for (const m of matches) if (!grouped.has(m.project.path)) rows.push({ kind: "project", m, group: null });
|
||||
return rows;
|
||||
}
|
||||
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"Group actions": "分组操作",
|
||||
"New Project Group": "新建项目组",
|
||||
"Group name:": "组名称:",
|
||||
"New Group…": "新建组…",
|
||||
"Remove from '{current}'": "从“{current}”移除",
|
||||
"Move to Group": "移动到组",
|
||||
"Rename Group…": "重命名组…",
|
||||
"Rename Project Group": "重命名项目组",
|
||||
"New Project Group…": "新建项目组…",
|
||||
"Remove Group": "移除组",
|
||||
"Cannot save project groups": "无法保存项目组",
|
||||
"Enter a group name": "请输入组名称",
|
||||
"A group named '{name}' already exists": "名为“{name}”的组已存在",
|
||||
"Link Gradle Projects": "关联 Gradle 项目",
|
||||
"Unlink Gradle Project": "取消关联 Gradle 项目",
|
||||
"Unlink Gradle project?": "取消关联 Gradle 项目?",
|
||||
|
||||
Reference in new issue
Заблокувати користувача