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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-05 13:24:15 +08:00
1 джерело a5fb380960
коміт 2760c91bde
12 files changed
+553 -51

No files matched your search

+2
Переглянути файл
@@ -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"
+1 -1
Переглянути файл
@@ -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};
+140 -22
Переглянути файл
@@ -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();
+19 -1
Переглянути файл
@@ -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> {
+2
Переглянути файл
@@ -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,
+15
Переглянути файл
@@ -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,
+116 -21
Переглянути файл
@@ -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>
+74 -2
Переглянути файл
@@ -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…",
+20
Переглянути файл
@@ -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;
+53 -1
Переглянути файл
@@ -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"]);
});
});
+98 -3
Переглянути файл
@@ -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;
}
+13
Переглянути файл
@@ -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 项目?",