diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index decb80b30..f6aa475e8 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -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" diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index 311d3f537..753dfac6a 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -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", diff --git a/crates/ide-workspace/src/lib.rs b/crates/ide-workspace/src/lib.rs index cc8fce280..a84c52fb7 100644 --- a/crates/ide-workspace/src/lib.rs +++ b/crates/ide-workspace/src/lib.rs @@ -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; diff --git a/crates/ide-workspace/src/project_discovery.rs b/crates/ide-workspace/src/project_discovery.rs new file mode 100644 index 000000000..76a8fea1a --- /dev/null +++ b/crates/ide-workspace/src/project_discovery.rs @@ -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, + pub projects: Vec, + 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 { + 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, cancel: &CancellationToken) -> IdeResult { + discover_with_limit(folders, cancel, MAX_ENTRIES) +} + +fn discover_with_limit( + folders: Vec, + cancel: &CancellationToken, + mut remaining: usize, +) -> IdeResult { + 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); + } +} diff --git a/crates/ide-workspace/src/roots/detect.rs b/crates/ide-workspace/src/roots/detect.rs index 61f18b9c6..471f63183 100644 --- a/crates/ide-workspace/src/roots/detect.rs +++ b/crates/ide-workspace/src/roots/detect.rs @@ -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", diff --git a/crates/ide-workspace/src/roots/mod.rs b/crates/ide-workspace/src/roots/mod.rs index 8fbd07c63..f4e47fee6 100644 --- a/crates/ide-workspace/src/roots/mod.rs +++ b/crates/ide-workspace/src/roots/mod.rs @@ -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; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1c3b231ec..3fdfcaa79 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -85,6 +85,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index 77408f7b1..6c3a5efa2 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -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, +) -> IdeResult { + 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] diff --git a/src/bindings.ts b/src/bindings.ts index 5d29d8687..a5f57e642 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -42,6 +42,7 @@ export const commands = { projectTrustAddLocation: (path: string) => typedError(__TAURI_INVOKE("project_trust_add_location", { path })), projectTrustRemoveLocation: (path: string) => typedError(__TAURI_INVOKE("project_trust_remove_location", { path })), workspaceCreate: (path: string, name: string, projects: string[]) => typedError(__TAURI_INVOKE("workspace_create", { path, name, projects })), + workspaceDiscoverProjects: (request: RequestId, folders: string[]) => typedError(__TAURI_INVOKE("workspace_discover_projects", { request, folders })), workspaceAttach: (path: string) => typedError(__TAURI_INVOKE("workspace_attach", { path })), workspaceUpdate: (name: string, projects: string[]) => typedError(__TAURI_INVOKE("workspace_update", { name, projects })), windowGroupCurrent: () => typedError(__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" | diff --git a/src/features/app/WorkspaceDialog.module.css b/src/features/app/WorkspaceDialog.module.css index 2bf82c564..3b2a9f470 100644 --- a/src/features/app/WorkspaceDialog.module.css +++ b/src/features/app/WorkspaceDialog.module.css @@ -66,3 +66,9 @@ .error { color: var(--danger); } +.candidate { + display: flex; + flex-direction: column; + gap: 3px; + overflow-wrap: anywhere; +} diff --git a/src/features/app/WorkspaceDialog.tsx b/src/features/app/WorkspaceDialog.tsx index 3e0f4fcd2..5b41d18c2 100644 --- a/src/features/app/WorkspaceDialog.tsx +++ b/src/features/app/WorkspaceDialog.tsx @@ -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(null); + const [selected, setSelected] = useState>(new Set()); + const [truncated, setTruncated] = useState(false); const [error, setError] = useState(""); + const request = useRef(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 ( + setDiscovery(null) }, + { + label: "Add Selected", + role: "default", + disabled: !selected.size, + onClick: () => { + setProjects((current) => [...new Set([...current, ...selected])]); + setDiscovery(null); + }, + }, + ]} + > +
+
+ Projects found +
+ + +
+
+

+ Choose the projects to link to this workspace. +

+
+ {discovery.projects.map((candidate) => { + const linked = projects.includes(candidate.path); + return ( +
+ + {candidate.relative_path} + {linked ? Already linked : basename(candidate.root)} + + } + onChange={(checked) => + setSelected((current) => { + const next = new Set(current); + if (checked) next.add(candidate.path); + else next.delete(candidate.path); + return next; + }) + } + /> +
+ ); + })} +
+ {truncated && ( +

+ + Search stopped at the entry limit. More projects may exist; choose a smaller folder to search again. + +

+ )} +
+
+ ); return ( void save(), }, ]} @@ -86,7 +210,12 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
@@ -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 } }} /> -

Project folders stay in place. Removing a project here keeps its files.

+ {scanning && ( +

+ Searching for projects… +

+ )} + {truncated && ( +

+ Search stopped at the entry limit. More projects may exist; choose a smaller folder to search again. +

+ )} {error && (
{error} diff --git a/src/features/app/__tests__/WorkspaceDialog.test.tsx b/src/features/app/__tests__/WorkspaceDialog.test.tsx index 2095e3a44..ee6d3025e 100644 --- a/src/features/app/__tests__/WorkspaceDialog.test.tsx +++ b/src/features/app/__tests__/WorkspaceDialog.test.tsx @@ -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()), + 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(); + 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(); + 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(); + 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(); + 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(); + 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"); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 6dff51d6b..9de3d5185 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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." } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 85b3cb7b9..0d756f499 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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.": "搜索已达到条目数量上限,可能还有更多项目。请选择较小的文件夹重新搜索。" }