feat(workspace): find projects under a chosen folder (#2167)

Adding projects to a workspace took one folder at a time. Choosing a folder that is not itself a project now scans below it, up to four levels and a shared entry budget, without following symlinks or descending into a project and skipping dependency, output and hidden directories. The projects found appear in a checklist with linked ones disabled; a folder with none is added as before. The scan is cancellable and runs in both the create and manage dialogs.

Fixes #2102
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 00:37:13 +08:00
1 джерело cb6bcd2fa2
коміт 68782a7220
14 files changed
+601 -20

No files matched your search

+1
Переглянути файл
@@ -279,6 +279,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "project_rename"
| "light_edit_open"
| "workspace_create"
| "workspace_discover_projects"
| "workspace_update"
| "workspace_attach"
| "cancel_request"
+1
Переглянути файл
@@ -32,6 +32,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
assert!(!host.open_project("main", &root).unwrap().is_trusted());
for command in [
"app_info",
"workspace_discover_projects",
"app_build_info",
"app_cli_install",
"app_cli_uninstall",
+1
Переглянути файл
@@ -12,6 +12,7 @@ pub mod file_refs;
pub mod history;
pub mod memory;
pub mod process;
pub mod project_discovery;
pub mod recent;
pub mod roots;
pub mod settings;
+247
Переглянути файл
@@ -0,0 +1,247 @@
//! Bounded discovery of independent projects selected through the workspace folder picker.
use ide_core::{CancellationToken, IdeError, IdeResult, IpcPath};
use serde::{Deserialize, Serialize};
use std::collections::{HashSet, VecDeque};
use std::path::{Path, PathBuf};
const MAX_DEPTH: usize = 4;
const MAX_ENTRIES: usize = 20_000;
const SKIP_DIRS: &[&str] = &["node_modules", "target", "build", "dist", "vendor", ".venv", ".git"];
#[derive(Debug, Serialize, Deserialize, specta::Type)]
pub struct DiscoveredProject {
pub path: IpcPath,
pub root: IpcPath,
pub relative_path: String,
}
#[derive(Debug, Default, Serialize, Deserialize, specta::Type)]
pub struct ProjectDiscovery {
/// Selected projects and folders with no discovered projects retain the direct-add behaviour.
pub direct: Vec<IpcPath>,
pub projects: Vec<DiscoveredProject>,
pub truncated: bool,
}
fn check_cancel(cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(()) }
}
fn is_project(dir: &Path, cancel: &CancellationToken) -> IdeResult<bool> {
for marker in crate::roots::BUILD_FILES.iter().chain([".git", ".idea", "CMakeLists.txt"].iter()) {
check_cancel(cancel)?;
if std::fs::symlink_metadata(dir.join(marker))
.is_ok_and(|m| m.is_file() || matches!(*marker, ".git" | ".idea") && m.is_dir())
{
return Ok(true);
}
}
Ok(false)
}
/// Does not follow symlinks or descend into a project. The entry budget spans all selected folders.
pub fn discover(folders: Vec<PathBuf>, cancel: &CancellationToken) -> IdeResult<ProjectDiscovery> {
discover_with_limit(folders, cancel, MAX_ENTRIES)
}
fn discover_with_limit(
folders: Vec<PathBuf>,
cancel: &CancellationToken,
mut remaining: usize,
) -> IdeResult<ProjectDiscovery> {
let mut result = ProjectDiscovery::default();
let mut seen_roots = HashSet::new();
let mut seen_projects = HashSet::new();
for root in folders {
check_cancel(cancel)?;
if !seen_roots.insert(root.clone()) {
continue;
}
let metadata = std::fs::symlink_metadata(&root)?;
// A link explicitly selected by the picker may still be added, but never traversed.
if metadata.is_symlink() || is_project(&root, cancel)? {
result.direct.push(root.into());
continue;
}
if !metadata.is_dir() {
return Err(IdeError::invalid("Choose a project folder"));
}
let mut found = false;
let mut queue = VecDeque::from([(root.clone(), 0)]);
while let Some((dir, depth)) = queue.pop_front() {
check_cancel(cancel)?;
if depth > 0 && is_project(&dir, cancel)? {
found = true;
if seen_projects.insert(dir.clone()) {
result.projects.push(DiscoveredProject {
relative_path: ide_core::ipc_path::to_ipc(dir.strip_prefix(&root).unwrap()),
path: dir.into(),
root: root.clone().into(),
});
}
continue;
}
if depth == MAX_DEPTH {
continue;
}
if remaining == 0 {
result.truncated = true;
continue;
}
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if depth == 0 => return Err(error.into()),
Err(_) => continue,
};
let mut children = Vec::new();
for entry in entries {
check_cancel(cancel)?;
if remaining == 0 {
result.truncated = true;
break;
}
remaining -= 1;
let Ok(entry) = entry else { continue };
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
children.push(entry.path());
}
}
children.sort();
queue.extend(children.into_iter().map(|path| (path, depth + 1)));
}
if !found {
result.direct.push(root.into());
}
}
check_cancel(cancel)?;
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn project(root: &Path, relative: &str, marker: &str) -> PathBuf {
let dir = root.join(relative);
fs::create_dir_all(&dir).unwrap();
if marker == ".idea" || marker == ".git" {
fs::create_dir(dir.join(marker)).unwrap();
} else {
fs::write(dir.join(marker), "").unwrap();
}
dir
}
#[test]
fn detects_shared_build_files_and_workspace_markers_without_descending() {
for marker in crate::roots::BUILD_FILES.iter().chain([".git", ".idea", "CMakeLists.txt"].iter()) {
let tmp = tempfile::tempdir().unwrap();
let parent = project(tmp.path(), "app", marker);
project(&parent, "module", "package.json");
let result = discover(vec![tmp.path().into()], &CancellationToken::new()).unwrap();
assert!(result.direct.is_empty(), "{marker}");
assert_eq!(result.projects.len(), 1, "{marker}");
assert_eq!(result.projects[0].path, parent);
assert_eq!(result.projects[0].relative_path, "app");
let direct = discover(vec![parent.clone()], &CancellationToken::new()).unwrap();
assert_eq!(direct.direct[0], parent);
assert!(direct.projects.is_empty());
}
}
#[test]
fn skips_dependencies_outputs_and_hidden_directories() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// .git is itself a marker, so test it below a hidden directory.
for skip in SKIP_DIRS.iter().filter(|name| **name != ".git").chain([&".hidden"]) {
project(root, &format!("{skip}/app"), "Cargo.toml");
}
project(root, ".hidden/.git/app", "go.mod");
let result = discover(vec![root.into()], &CancellationToken::new()).unwrap();
assert!(result.projects.is_empty());
assert_eq!(result.direct[0], root);
}
#[test]
fn finds_depth_four_but_not_five_and_retains_empty_folders() {
let tmp = tempfile::tempdir().unwrap();
let expected = project(tmp.path(), "a/b/c/app", "pom.xml");
project(tmp.path(), "deep/b/c/d/app", "package.json");
let empty = tmp.path().join("empty");
fs::create_dir(&empty).unwrap();
let result = discover(vec![tmp.path().into(), empty.clone()], &CancellationToken::new()).unwrap();
assert_eq!(result.projects.len(), 1);
assert_eq!(result.projects[0].path, expected);
assert_eq!(result.direct[0], empty);
}
#[test]
fn deduplicates_overlapping_selections_and_shares_the_entry_budget() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
project(root, "a/app", "go.mod");
let result = discover(vec![root.into(), root.join("a"), root.into()], &CancellationToken::new()).unwrap();
assert_eq!(result.projects.len(), 1);
assert!(result.direct.is_empty());
let result = discover_with_limit(vec![root.into(), root.join("a")], &CancellationToken::new(), 1).unwrap();
assert!(result.truncated);
assert!(result.projects.is_empty());
}
#[test]
fn cancellation_also_stops_direct_adds() {
let tmp = tempfile::tempdir().unwrap();
let root = project(tmp.path(), "app", "Cargo.toml");
let cancel = CancellationToken::new();
cancel.cancel();
let error = discover(vec![root], &cancel).unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled);
}
#[test]
fn git_worktree_files_are_project_markers() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".git"), "gitdir: ../main/.git/worktrees/app").unwrap();
let result = discover(vec![tmp.path().into()], &CancellationToken::new()).unwrap();
assert_eq!(result.direct[0], tmp.path().to_path_buf());
assert!(result.projects.is_empty());
}
#[test]
fn reports_truncation_and_keeps_projects_found_before_the_limit() {
let tmp = tempfile::tempdir().unwrap();
let first = tmp.path().join("first");
let expected = project(&first, "app", "Cargo.toml");
let second = tmp.path().join("second");
project(&second, "app", "package.json");
let result = discover_with_limit(vec![first, second.clone()], &CancellationToken::new(), 1).unwrap();
assert!(result.truncated);
assert_eq!(result.projects.len(), 1);
assert_eq!(result.projects[0].path, expected);
assert_eq!(result.direct[0], second);
}
#[cfg(unix)]
#[test]
fn never_follows_symlink_loops_or_linked_projects_or_markers() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("container");
fs::create_dir(&root).unwrap();
let external = project(tmp.path(), "outside", "package.json");
symlink(&root, root.join("loop")).unwrap();
symlink(&external, root.join("linked")).unwrap();
symlink(external.join("package.json"), root.join("package.json")).unwrap();
let result = discover(vec![root.clone()], &CancellationToken::new()).unwrap();
assert!(result.projects.is_empty());
assert_eq!(result.direct[0], root);
}
}
+1 -1
Переглянути файл
@@ -18,7 +18,7 @@ const SKIP_DIRS: &[&str] =
&["node_modules", "target", "build", "dist", "out", "venv", "__pycache__", "vendor", "bower_components"];
/// Build files whose changes can change the detected roots.
const BUILD_FILES: &[&str] = &[
pub(crate) const BUILD_FILES: &[&str] = &[
"pom.xml",
"build.gradle",
"build.gradle.kts",
+1
Переглянути файл
@@ -6,6 +6,7 @@
//! project-relative and `/`-separated; `""` is the project root itself.
mod detect;
pub(crate) use detect::BUILD_FILES;
mod gradle;
mod java;
mod maven;
+1
Переглянути файл
@@ -85,6 +85,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
app::project_trust_add_location,
app::project_trust_remove_location,
workspace::workspace_create,
workspace::workspace_discover_projects,
workspace::workspace_attach,
workspace::workspace_update,
window_group::window_group_current,
+18 -2
Переглянути файл
@@ -1,9 +1,25 @@
use crate::state::AppState;
use ide_core::{IdeError, IdeResult};
use ide_core::{IdeError, IdeResult, RequestId};
use ide_host::ops::project::ProjectInfo;
use ide_workspace::workspace::Workspace;
use std::path::PathBuf;
use tauri::{Manager, WebviewWindow};
use tauri::{Manager, State, WebviewWindow};
#[tauri::command]
#[specta::specta]
pub async fn workspace_discover_projects(
state: State<'_, AppState>,
request: RequestId,
folders: Vec<PathBuf>,
) -> IdeResult<ide_workspace::project_discovery::ProjectDiscovery> {
let cancel = state.begin_request(request);
let result =
tauri::async_runtime::spawn_blocking(move || ide_workspace::project_discovery::discover(folders, &cancel))
.await
.map_err(|e| IdeError::internal(e.to_string()));
state.end_request(request);
result?
}
#[tauri::command]
#[specta::specta]
+14
Переглянути файл
@@ -42,6 +42,7 @@ export const commands = {
projectTrustAddLocation: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("project_trust_add_location", { path })),
projectTrustRemoveLocation: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("project_trust_remove_location", { path })),
workspaceCreate: (path: string, name: string, projects: string[]) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_create", { path, name, projects })),
workspaceDiscoverProjects: (request: RequestId, folders: string[]) => typedError<ProjectDiscovery, IdeError>(__TAURI_INVOKE("workspace_discover_projects", { request, folders })),
workspaceAttach: (path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_attach", { path })),
workspaceUpdate: (name: string, projects: string[]) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("workspace_update", { name, projects })),
windowGroupCurrent: () => typedError<WindowTab[], IdeError>(__TAURI_INVOKE("window_group_current")),
@@ -3316,6 +3317,12 @@ export type DirectoryHistory = {
deleted: IpcPath[],
};
export type DiscoveredProject = {
path: IpcPath,
root: IpcPath,
relative_path: string,
};
/** Checking out remote `origin/x` while local `x` has commits `origin/x` lacks. */
export type DivergedLocal =
/** Rebase local `x` onto `origin/x` (IDEA "Rebase onto Remote"). */
@@ -6450,6 +6457,13 @@ export type ProfilerInstall = {
cwd: IpcPath | null,
};
export type ProjectDiscovery = {
/** Selected projects and folders with no discovered projects retain the direct-add behaviour. */
direct: IpcPath[],
projects: DiscoveredProject[],
truncated: boolean,
};
export type ProjectFileSet =
/** "Project Files": everything not excluded. */
"all" |
+6
Переглянути файл
@@ -66,3 +66,9 @@
.error {
color: var(--danger);
}
.candidate {
display: flex;
flex-direction: column;
gap: 3px;
overflow-wrap: anywhere;
}
+153 -13
Переглянути файл
@@ -1,10 +1,11 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { create } from "zustand";
import { call, commands } from "@/platform/ipc";
import { call, commands, isCancelled, nextRequestId, type ProjectDiscovery, type RequestId } from "@/platform/ipc";
import { invokeLocal } from "@/platform/tauriCore";
import { basename, dirname, useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
import { tx } from "@/platform/i18n";
import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui";
import { Button, Checkbox, Dialog, Icon, IconButton, Input, T } from "@/ui";
import { openProjectWithPolicy } from "./windows";
import styles from "./WorkspaceDialog.module.css";
@@ -28,7 +29,25 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
);
const [adding, setAdding] = useState("");
const [busy, setBusy] = useState(false);
const [scanning, setScanning] = useState(false);
const [discovery, setDiscovery] = useState<ProjectDiscovery | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [truncated, setTruncated] = useState(false);
const [error, setError] = useState("");
const request = useRef<RequestId | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
if (request.current !== null) {
// Discovery and its native folder picker stay local in remote windows too.
void invokeLocal("cancel_request", { id: request.current }).catch(() => {});
request.current = null;
}
};
}, []);
const disabled = busy || scanning;
const add = (value: string) => {
const path = value.trim();
if (path && !projects.includes(path)) setProjects([...projects, path]);
@@ -42,11 +61,38 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
title: tx(target === "project" ? "Add Projects" : "Workspace Location"),
});
const paths = result === null ? [] : Array.isArray(result) ? result : [result];
if (target === "workspace" && paths[0]) setPath(paths[0]);
else setProjects((current) => [...new Set([...current, ...paths])]);
if (!mounted.current || !paths.length) return;
if (target === "workspace") {
setPath(paths[0]);
return;
}
const id = nextRequestId();
request.current = id;
setScanning(true);
setError("");
setTruncated(false);
try {
const found = await call(commands.workspaceDiscoverProjects(id, paths));
if (request.current !== id) return;
setProjects((current) => [...new Set([...current, ...found.direct])]);
setTruncated(found.truncated);
if (found.projects.length) {
setDiscovery(found);
setSelected(
new Set(found.projects.map((p) => p.path).filter((p) => !projects.includes(p) && !found.direct.includes(p))),
);
}
} catch (e) {
if (request.current === id && !isCancelled(e)) setError(e instanceof Error ? e.message : String(e));
} finally {
if (request.current === id) {
request.current = null;
setScanning(false);
}
}
};
const save = async () => {
if (busy) return;
if (disabled || discovery) return;
setBusy(true);
setError("");
try {
@@ -65,6 +111,84 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
setBusy(false);
}
};
if (discovery)
return (
<Dialog
open
onClose={onClose}
title="Choose Projects"
width={640}
buttons={[
{ label: "Back", role: "cancel", onClick: () => setDiscovery(null) },
{
label: "Add Selected",
role: "default",
disabled: !selected.size,
onClick: () => {
setProjects((current) => [...new Set([...current, ...selected])]);
setDiscovery(null);
},
},
]}
>
<div className={styles.body}>
<div className={styles.heading}>
<T>Projects found</T>
<div className={styles.row}>
<Button
size="small"
onClick={() =>
setSelected(new Set(discovery.projects.map((p) => p.path).filter((p) => !projects.includes(p))))
}
>
Select All
</Button>
<Button size="small" onClick={() => setSelected(new Set())}>
Select None
</Button>
</div>
</div>
<p className={styles.hint}>
<T>Choose the projects to link to this workspace.</T>
</p>
<div className={styles.projects} role="group" aria-label={tx("Projects found")}>
{discovery.projects.map((candidate) => {
const linked = projects.includes(candidate.path);
return (
<div className={styles.project} key={candidate.path}>
<Checkbox
checked={selected.has(candidate.path)}
disabled={linked}
aria-label={candidate.relative_path}
label={
<span className={styles.candidate} title={candidate.path}>
<span>{candidate.relative_path}</span>
<span className={styles.hint}>{linked ? <T>Already linked</T> : basename(candidate.root)}</span>
</span>
}
onChange={(checked) =>
setSelected((current) => {
const next = new Set(current);
if (checked) next.add(candidate.path);
else next.delete(candidate.path);
return next;
})
}
/>
</div>
);
})}
</div>
{truncated && (
<p className={styles.hint} role="status">
<T>
Search stopped at the entry limit. More projects may exist; choose a smaller folder to search again.
</T>
</p>
)}
</div>
</Dialog>
);
return (
<Dialog
open
@@ -78,7 +202,7 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
{
label: mode === "create" ? "Create" : "Apply",
role: "default",
disabled: busy || !name.trim() || !path.trim(),
disabled: disabled || !name.trim() || !path.trim(),
onClick: () => void save(),
},
]}
@@ -86,7 +210,12 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
<div className={styles.body}>
<label className={styles.field}>
<T>Name</T>
<Input aria-label="Workspace name" value={name} disabled={busy} onChange={(e) => setName(e.target.value)} />
<Input
aria-label="Workspace name"
value={name}
disabled={disabled}
onChange={(e) => setName(e.target.value)}
/>
</label>
<label className={styles.field}>
<T>Location</T>
@@ -94,13 +223,14 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
<Input
aria-label="Workspace location"
value={path}
disabled={busy || mode === "manage"}
disabled={disabled || mode === "manage"}
onChange={(e) => setPath(e.target.value)}
/>
{mode === "create" && (
<IconButton
icon="folder-open"
title="Choose workspace location"
disabled={disabled}
onClick={() => void browse("workspace").catch((e) => setError(String(e)))}
/>
)}
@@ -111,7 +241,7 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
<Button
size="small"
icon="plus"
disabled={busy}
disabled={disabled}
onClick={() => void browse("project").catch((e) => setError(String(e)))}
>
Add Projects…
@@ -128,7 +258,7 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
<IconButton
icon="close"
title={tx`Remove ${basename(root)}`}
disabled={busy}
disabled={disabled}
onClick={() => setProjects(projects.filter((p) => p !== root))}
/>
</div>
@@ -144,7 +274,7 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
aria-label="Project folder path"
placeholder="Project folder path"
value={adding}
disabled={busy}
disabled={disabled}
onChange={(e) => setAdding(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
@@ -154,13 +284,23 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
}
}}
/>
<Button disabled={busy || !adding.trim()} onClick={() => add(adding)}>
<Button disabled={disabled || !adding.trim()} onClick={() => add(adding)}>
Add
</Button>
</div>
<p className={styles.hint}>
<T>Project folders stay in place. Removing a project here keeps its files.</T>
</p>
{scanning && (
<p className={styles.hint} role="status">
<T>Searching for projects…</T>
</p>
)}
{truncated && (
<p className={styles.hint} role="status">
<T>Search stopped at the entry limit. More projects may exist; choose a smaller folder to search again.</T>
</p>
)}
{error && (
<div className={styles.error} role="alert">
{error}
+138 -2
Переглянути файл
@@ -1,19 +1,155 @@
import { beforeEach, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { commands } from "@/platform/ipc";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { open } from "@tauri-apps/plugin-dialog";
import { commands, type ProjectDiscovery } from "@/platform/ipc";
import { invokeLocal } from "@/platform/tauriCore";
import { useProject } from "@/platform/project";
import { WorkspaceDialogHost, showWorkspaceDialog } from "../WorkspaceDialog";
import { openProjectWithPolicy } from "../windows";
vi.mock("../windows", () => ({ openProjectWithPolicy: vi.fn(async () => {}) }));
vi.mock("@/platform/remote", () => ({ isRemoteWindow: () => false }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
vi.mock("@/platform/tauriCore", async (original) => ({
...(await original<typeof import("@/platform/tauriCore")>()),
invokeLocal: vi.fn(async () => {}),
}));
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(openProjectWithPolicy).mockClear();
vi.mocked(open).mockReset();
vi.mocked(invokeLocal).mockClear();
useProject.setState({ project: null, settings: null, lightEdit: false });
});
const discovered = (path: string, root: string) => ({ path, root, relative_path: path.slice(root.length + 1) });
it.each(["create", "manage"] as const)(
"merges folder selections and adds only checked projects in %s mode",
async (mode) => {
const project = {
root: mode === "manage" ? "/work/workspace" : "/work/one/linked",
name: "Workspace",
trusted: true,
isGit: false,
settings: {},
workspace: mode === "manage" ? { name: "Workspace", projects: ["/work/one/linked"], version: 1 } : null,
};
useProject.setState({ project, settings: {} } as never);
vi.mocked(open).mockResolvedValue(["/work/one", "/work/two", "/work/empty", "/work/one/direct"]);
const scan = vi.spyOn(commands, "workspaceDiscoverProjects").mockResolvedValue({
status: "ok",
data: {
direct: ["/work/empty", "/work/one/direct"],
projects: [
discovered("/work/one/linked", "/work/one"),
discovered("/work/one/direct", "/work/one"),
discovered("/work/one/frontend", "/work/one"),
discovered("/work/two/group/backend", "/work/two"),
],
truncated: false,
},
});
const create = vi.spyOn(commands, "workspaceCreate").mockResolvedValue({ status: "ok", data: "/work/workspace" });
const update = vi.spyOn(commands, "workspaceUpdate").mockResolvedValue({ status: "ok", data: project } as never);
showWorkspaceDialog(mode);
render(<WorkspaceDialogHost />);
fireEvent.click(screen.getByRole("button", { name: "Add Projects…" }));
const linked = await screen.findByRole("checkbox", { name: "linked" });
expect(scan).toHaveBeenCalledWith(expect.any(Number), [
"/work/one",
"/work/two",
"/work/empty",
"/work/one/direct",
]);
expect(linked).toBeDisabled();
expect(linked).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: "direct" })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: "direct" })).not.toBeChecked();
expect(screen.getAllByText("Already linked")).toHaveLength(2);
expect(screen.getByRole("checkbox", { name: "frontend" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "group/backend" })).toBeChecked();
fireEvent.click(screen.getByRole("button", { name: "Select None" }));
expect(screen.getByRole("button", { name: "Add Selected" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Select All" }));
expect(linked).not.toBeChecked();
fireEvent.click(screen.getByRole("checkbox", { name: "group/backend" }));
fireEvent.click(screen.getByRole("button", { name: "Add Selected" }));
expect(screen.getAllByRole("listitem")).toHaveLength(4);
fireEvent.click(screen.getByRole("button", { name: mode === "create" ? /^Create$/ : /^Apply$/ }));
const included = ["/work/one/linked", "/work/empty", "/work/one/direct", "/work/one/frontend"];
await waitFor(() =>
mode === "create"
? expect(create).toHaveBeenCalledWith("/work/one/workspace", "Workspace", included)
: expect(update).toHaveBeenCalledWith("Workspace", included),
);
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
},
);
it("adds direct and empty folders without a checklist and retains an entry-limit warning", async () => {
vi.mocked(open).mockResolvedValue(["/work/direct", "/work/empty"]);
vi.spyOn(commands, "workspaceDiscoverProjects").mockResolvedValue({
status: "ok",
data: { direct: ["/work/direct", "/work/empty"], projects: [], truncated: true },
});
showWorkspaceDialog("create");
render(<WorkspaceDialogHost />);
fireEvent.click(screen.getByRole("button", { name: "Add Projects…" }));
expect(await screen.findAllByRole("listitem")).toHaveLength(2);
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Search stopped at the entry limit.");
});
it("returns from the checklist without adding unchecked projects", async () => {
vi.mocked(open).mockResolvedValue("/work");
vi.spyOn(commands, "workspaceDiscoverProjects").mockResolvedValue({
status: "ok",
data: { direct: [], projects: [discovered("/work/app", "/work")], truncated: false },
});
showWorkspaceDialog("create");
render(<WorkspaceDialogHost />);
fireEvent.click(screen.getByRole("button", { name: "Add Projects…" }));
fireEvent.click(await screen.findByRole("button", { name: "Back" }));
expect(screen.queryByRole("listitem")).not.toBeInTheDocument();
});
it("cancels scanning locally on close and ignores late results", async () => {
vi.mocked(open).mockResolvedValue("/work");
let finish!: (result: { status: "ok"; data: ProjectDiscovery }) => void;
const scan = vi.spyOn(commands, "workspaceDiscoverProjects").mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const routedCancel = vi.spyOn(commands, "cancelRequest");
showWorkspaceDialog("create");
render(<WorkspaceDialogHost />);
fireEvent.click(screen.getByRole("button", { name: "Add Projects…" }));
expect(await screen.findByRole("status")).toHaveTextContent("Searching for projects…");
expect(screen.getByRole("button", { name: "Add Projects…" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Create" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(invokeLocal).toHaveBeenCalledWith("cancel_request", { id: scan.mock.calls[0]![0] });
expect(routedCancel).not.toHaveBeenCalled();
await act(async () =>
finish({ status: "ok", data: { direct: [], projects: [discovered("/work/app", "/work")], truncated: false } }),
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("does not scan when the native picker is cancelled", async () => {
vi.mocked(open).mockResolvedValue(null);
const scan = vi.spyOn(commands, "workspaceDiscoverProjects");
showWorkspaceDialog("create");
render(<WorkspaceDialogHost />);
fireEvent.click(screen.getByRole("button", { name: "Add Projects…" }));
await waitFor(() => expect(open).toHaveBeenCalled());
expect(scan).not.toHaveBeenCalled();
});
it("creates a workspace from the pending project field without an extra Add click", async () => {
const save = vi.spyOn(commands, "workspaceCreate").mockResolvedValue({ status: "ok", data: "/work/full" });
showWorkspaceDialog("create");
+10 -1
Переглянути файл
@@ -493,5 +493,14 @@
"Copied": "Copied",
"Could not copy. Select the details below and copy them.": "Could not copy. Select the details below and copy them.",
"Startup error details": "Startup error details",
"Startup did not finish within 30 seconds.": "Startup did not finish within 30 seconds."
"Startup did not finish within 30 seconds.": "Startup did not finish within 30 seconds.",
"Choose Projects": "Choose Projects",
"Add Selected": "Add Selected",
"Projects found": "Projects found",
"Select All": "Select All",
"Select None": "Select None",
"Choose the projects to link to this workspace.": "Choose the projects to link to this workspace.",
"Already linked": "Already linked",
"Searching for projects…": "Searching for projects…",
"Search stopped at the entry limit. More projects may exist; choose a smaller folder to search again.": "Search stopped at the entry limit. More projects may exist; choose a smaller folder to search again."
}
+9 -1
Переглянути файл
@@ -6835,5 +6835,13 @@
"Copy details": "复制详情",
"Could not copy. Select the details below and copy them.": "无法复制。请选择下方详情并手动复制。",
"Startup error details": "启动错误详情",
"Startup did not finish within 30 seconds.": "启动未在 30 秒内完成。"
"Startup did not finish within 30 seconds.": "启动未在 30 秒内完成。",
"Choose Projects": "选择项目",
"Add Selected": "添加所选项目",
"Projects found": "找到的项目",
"Select None": "全不选",
"Choose the projects to link to this workspace.": "选择要关联到此工作区的项目。",
"Already linked": "已关联",
"Searching for projects…": "正在查找项目…",
"Search stopped at the entry limit. More projects may exist; choose a smaller folder to search again.": "搜索已达到条目数量上限,可能还有更多项目。请选择较小的文件夹重新搜索。"
}