дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
cb6bcd2fa2
коміт
68782a7220
14 files changed
+601
-20
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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" |
|
||||
|
||||
@@ -66,3 +66,9 @@
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.candidate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.": "搜索已达到条目数量上限,可能还有更多项目。请选择较小的文件夹重新搜索。"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача