From 2760c91bde913257e0853d7f906bca9aa05bcc59 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:24:15 +0800 Subject: [PATCH] feat(welcome): group recent projects (#1465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ide-host/src/trust.rs | 2 + crates/ide-workspace/src/lib.rs | 2 +- crates/ide-workspace/src/recent.rs | 162 ++++++++++++++++++++---- src-tauri/src/commands/app.rs | 20 ++- src-tauri/src/commands/mod.rs | 2 + src/bindings.ts | 15 +++ src/features/welcome/ProjectsPage.tsx | 137 +++++++++++++++++--- src/features/welcome/RecentList.tsx | 76 ++++++++++- src/features/welcome/Welcome.module.css | 20 +++ src/features/welcome/recent.test.ts | 54 +++++++- src/features/welcome/recent.ts | 101 ++++++++++++++- src/platform/locales/text/zh.json | 13 ++ 12 files changed, 553 insertions(+), 51 deletions(-) diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 2ddd6859d..adb41ae46 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -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" diff --git a/crates/ide-workspace/src/lib.rs b/crates/ide-workspace/src/lib.rs index 18c29fc18..cc8fce280 100644 --- a/crates/ide-workspace/src/lib.rs +++ b/crates/ide-workspace/src/lib.rs @@ -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}; diff --git a/crates/ide-workspace/src/recent.rs b/crates/ide-workspace/src/recent.rs index 3c05e178b..ebb49a69f 100644 --- a/crates/ide-workspace/src/recent.rs +++ b/crates/ide-workspace/src/recent.rs @@ -16,10 +16,78 @@ pub struct RecentProject { pub const MAX_RECENT_PROJECTS: usize = 30; -/// Persisted at `/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, + #[serde(default = "expanded")] + pub expanded: bool, +} + +fn expanded() -> bool { + true +} + +/// Persisted at `/recent.json`, newest first; groups at `/recent-groups.json`. pub struct RecentProjects { file: PathBuf, items: Mutex>, + groups_file: PathBuf, + groups: Mutex>, +} + +fn read_json(file: &Path) -> Option { + 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(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) -> Vec { + 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, 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 = 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 = 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 { self.items.lock().clone() } + pub fn groups(&self) -> Vec { + self.groups.lock().clone() + } + + /// Replaces the groups (paths that are not recent projects are dropped). + pub fn set_groups(&self, groups: Vec) -> IdeResult> { + 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) -> 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 { @@ -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(); diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 8c5e994c5..64b253496 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -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 { + 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, +) -> IdeResult> { + state.recent.set_groups(groups) +} + #[tauri::command] #[specta::specta] pub async fn open_project(window: Window, path: PathBuf) -> IdeResult { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d882ce9ec..2b917c27d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -65,6 +65,8 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src/bindings.ts b/src/bindings.ts index 080793a20..52c9a9d13 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -10,6 +10,13 @@ export const commands = { appInfo: () => __TAURI_INVOKE("app_info"), recentProjects: () => __TAURI_INVOKE("recent_projects"), removeRecentProject: (path: string) => typedError(__TAURI_INVOKE("remove_recent_project", { path })), + /** The welcome screen's project groups. */ + recentProjectGroups: () => __TAURI_INVOKE("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(__TAURI_INVOKE("set_recent_project_groups", { groups })), openProject: (path: string) => typedError(__TAURI_INVOKE("open_project", { path })), projectTrustStatus: (path: string) => typedError(__TAURI_INVOKE("project_trust_status", { path })), projectTrustList: () => typedError(__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, diff --git a/src/features/welcome/ProjectsPage.tsx b/src/features/welcome/ProjectsPage.tsx index a5c218793..10d4ea357 100644 --- a/src/features/welcome/ProjectsPage.tsx +++ b/src/features/welcome/ProjectsPage.tsx @@ -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(null); + useEffect(() => { + if (selected) ref.current?.scrollIntoView?.({ block: "nearest" }); + }, [selected]); + return ( +
e.preventDefault()} + onClick={() => { + onSelect(); + void setGroupExpanded(group.name, !group.expanded); + }} + onContextMenu={(e) => { + e.preventDefault(); + onSelect(); + showContextMenu(e, groupMenu(group)); + }} + > + + + {group.name} + {count} + { + e.stopPropagation(); + onSelect(); + const r = e.currentTarget.getBoundingClientRect(); + showContextMenu({ x: r.left, y: r.bottom + 2 }, groupMenu(group)); + }} + /> +
+ ); +} + 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( e: Pick, - 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(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) => ( - setActive(i)} - onOpen={() => void openRecent(m.project)} - /> - ))} + {items.map((row, i) => + row.kind === "group" ? ( + setActive(i)} + /> + ) : ( + setActive(i)} + onOpen={() => void openRecent(row.m.project)} + /> + ), + )} {items.length === 0 && (
No matching projects diff --git a/src/features/welcome/RecentList.tsx b/src/features/welcome/RecentList.tsx index 4daccc5b9..88bd428cb 100644 --- a/src/features/welcome/RecentList.tsx +++ b/src/features/welcome/RecentList.tsx @@ -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…", diff --git a/src/features/welcome/Welcome.module.css b/src/features/welcome/Welcome.module.css index da2a3032e..a97c47ae0 100644 --- a/src/features/welcome/Welcome.module.css +++ b/src/features/welcome/Welcome.module.css @@ -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; diff --git a/src/features/welcome/recent.test.ts b/src/features/welcome/recent.test.ts index 4534d688e..322e9c83e 100644 --- a/src/features/welcome/recent.test.ts +++ b/src/features/welcome/recent.test.ts @@ -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"]); + }); +}); diff --git a/src/features/welcome/recent.ts b/src/features/welcome/recent.ts index 98cc4b4c9..ecb817e51 100644 --- a/src/features/welcome/recent.ts +++ b/src/features/welcome/recent.ts @@ -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(() => ({ 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(); + 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; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b94bcc45f..99b57a05f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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 项目?",