/** * Settings › Project Structure (IDEA's module "Sources" tab): the content * root's Sources / Tests / Resources / Test Resources / Excluded folders. * Roots detected from build files (`pom.xml`, `build.gradle`, `Cargo.toml`, * `go.mod`, `pyproject.toml`, `package.json`, `composer.json`) apply until the user marks or * unmarks the folder (`projectStructure.*`, project scope). Roots drive the * project tree icons, package inference for new classes, the "Project * Production / Test Files" scopes, and Excluded folders are skipped by the * tree, Find in Files and indexing. Content roots besides the project folder * (`projectStructure.contentRoots`) are listed in the Project view and searched * by Find in Files; "Exclude files" name patterns (`projectStructure.excludeFiles`) * are hidden from the tree and skipped by search and indexing. */ import { useCallback, useEffect, useId, useMemo, useState } from "react"; import clsx from "clsx"; import { Button, Icon, IconButton, Input, Tree, folderIconName, type FolderKind, T } from "@/ui"; import { call, commands, isTauri, type FileEntry } from "@/platform/ipc"; import { relativePath, useProject } from "@/platform/project"; import { PROJECT_STRUCTURE_KEYS, effectiveRoots, markFolder, restoreDetected, rootRole, unmarkedRoots, type FolderListKey, type FolderRole, type ProjectStructureValid, } from "@/platform/languageSettings"; import { initProjectRoots, useDetectedRoots } from "@/platform/projectRoots"; import { choosePath } from "./api"; import { useProjectSetting } from "./components"; import styles from "./Sdk.module.css"; import { tx } from "@/platform/i18n"; interface DirNode { path: string; name: string; /** Project-relative; null outside the project (another content root). */ rel: string | null; /** A content root besides the project folder. */ contentRoot?: boolean; } const inside = (root: string, path: string) => path === root || path.startsWith(root + "/"); const ROLES: Array<{ role: FolderRole; label: string; title: string; kind: FolderKind; cls: string }> = [ { role: "source", label: "Sources", title: "Source Folders", kind: "source", cls: styles.sourceTitle! }, { role: "test", label: "Tests", title: "Test Source Folders", kind: "test", cls: styles.testTitle! }, { role: "resources", label: "Resources", title: "Resource Folders", kind: "resources", cls: styles.resourcesTitle! }, { role: "testResources", label: "Test Resources", title: "Test Resource Folders", kind: "testResources", cls: styles.testResourcesTitle!, }, { role: "excluded", label: "Excluded", title: "Excluded Folders", kind: "excluded", cls: styles.excludedTitle! }, ]; async function readDirs(root: string, path: string): Promise { if (!isTauri()) return []; const entries: FileEntry[] = await call(commands.fsReadDir(path)); return entries .filter((e) => e.kind === "dir" || (e.kind === "symlink" && e.targetKind === "dir")) .filter((e) => e.name !== ".git") .map((e) => ({ path: e.path, name: e.name, rel: inside(root, e.path) ? relativePath(root, e.path) : null })); } function useStructure(): [ProjectStructureValid, (next: ProjectStructureValid) => void] { const [sourceRoots, setSource] = useProjectSetting("projectStructure.sourceRoots"); const [testRoots, setTest] = useProjectSetting("projectStructure.testRoots"); const [resourceRoots, setResources] = useProjectSetting("projectStructure.resourceRoots"); const [testResourceRoots, setTestResources] = useProjectSetting( "projectStructure.testResourceRoots", ); const [excluded, setExcluded] = useProjectSetting("projectStructure.excluded"); const [unmarked, setUnmarked] = useProjectSetting("projectStructure.unmarked"); const [contentRoots] = useProjectSetting("projectStructure.contentRoots"); const [excludeFiles] = useProjectSetting("projectStructure.excludeFiles"); const ps = useMemo( () => ({ sourceRoots: sourceRoots ?? [], testRoots: testRoots ?? [], resourceRoots: resourceRoots ?? [], testResourceRoots: testResourceRoots ?? [], excluded: excluded ?? [], unmarked: unmarked ?? [], contentRoots: contentRoots ?? [], excludeFiles: excludeFiles ?? "", }), [sourceRoots, testRoots, resourceRoots, testResourceRoots, excluded, unmarked, contentRoots, excludeFiles], ); const set = useCallback( (next: ProjectStructureValid) => { const setters: Record void> = { sourceRoots: setSource, testRoots: setTest, resourceRoots: setResources, testResourceRoots: setTestResources, excluded: setExcluded, unmarked: setUnmarked, }; for (const k of PROJECT_STRUCTURE_KEYS) if (JSON.stringify(next[k]) !== JSON.stringify(ps[k])) setters[k](next[k]); }, [ps, setSource, setTest, setResources, setTestResources, setExcluded, setUnmarked], ); return [ps, set]; } /** "+ Add Content Root" and the content roots, the project folder first (IDEA's module Sources tab). */ function ContentRoots({ root }: { root: string }) { const [roots, setRoots] = useProjectSetting("projectStructure.contentRoots"); const list = roots ?? []; const add = async () => { const picked = await choosePath({ directory: true, title: "Add Content Root", defaultPath: root }); if (!picked || inside(root, picked) || list.includes(picked)) return; setRoots([...list, picked]); }; return (
{root}
{list.map((r) => (
{r} setRoots(list.filter((x) => x !== r))} />
))}
); } function ExcludeFiles() { const [value, setValue] = useProjectSetting("projectStructure.excludeFiles"); const id = useId(); return (
setValue(e.target.value)} />
Use ; to separate name patterns, * for any number of symbols, ? for one.
); } export function ProjectStructurePage() { const root = useProject((s) => s.project?.root ?? null); const name = useProject((s) => s.project?.name ?? ""); const [ps, setPs] = useStructure(); const detected = useDetectedRoots(); const roots = useMemo(() => effectiveRoots(detected, ps), [detected, ps]); useEffect(() => initProjectRoots(), []); const [children, setChildren] = useState>(new Map()); const [expanded, setExpanded] = useState>(() => new Set(root ? [root] : [])); const [selected, setSelected] = useState(null); const load = useCallback( async (node: DirNode) => { if (!root) return; const dirs = await readDirs(root, node.path); setChildren((m) => new Map(m).set(node.path, dirs)); }, [root], ); const rootNode = useMemo(() => (root ? { path: root, name, rel: "" } : null), [root, name]); const treeRoots = useMemo( () => rootNode ? [ rootNode, ...ps.contentRoots.map((p) => ({ path: p, name: p.slice(p.lastIndexOf("/") + 1) || p, rel: null, contentRoot: true, })), ] : [], [rootNode, ps.contentRoots], ); useEffect(() => { if (!root) return; let live = true; for (const dir of [root, ...ps.contentRoots]) void readDirs(root, dir) .then((dirs) => live && setChildren((m) => new Map(m).set(dir, dirs))) .catch(() => {}); return () => { live = false; }; }, [root, ps.contentRoots]); if (!rootNode) return (
Open a project to configure its folders.
); const markable = selected?.rel ?? null; const role = markable !== null ? rootRole(roots, markable) : null; const mark = (r: FolderRole) => { if (markable === null) return; setPs(markFolder(ps, markable, role === r ? null : r, detected)); }; const unmarkedDetected = unmarkedRoots(detected, ps); const restore = () => setPs(restoreDetected(ps, detected)); return (
Mark as: {ROLES.map((r) => ( ))}
aria-label="Content root" roots={treeRoots} getKey={(n) => n.path} getLabel={(n) => n.name} getChildren={(n) => children.get(n.path)} isExpandable={() => true} loadChildren={load} expanded={expanded} onExpandedChange={(set) => setExpanded(set)} multiSelect={false} selected={selected ? new Set([selected.path]) : new Set()} onSelectionChange={(_s, lead) => setSelected(lead)} getIcon={(n) => { if (n.rel === null) return n.contentRoot ? "module" : folderIconName("plain"); const r = rootRole(roots, n.rel); if (!n.rel && !r) return "module"; return folderIconName(r ? ROLES.find((x) => x.role === r)!.kind : "plain"); }} getDetail={(n) => (n.rel === "" ? (root ?? undefined) : n.contentRoot ? n.path : undefined)} />
{ROLES.map((r) => { const list = roots.filter((x) => x.kind === r.role); return (
{r.title}
{list.length === 0 && (
None
)} {list.map((x) => { const shown = x.path || name || "."; return (
{shown} {x.detectedFrom && ( {tx`detected from ${x.detectedFrom}`} )} setPs(markFolder(ps, x.path, null, detected))} />
); })}
); })} {unmarkedDetected.length > 0 && (
{unmarkedDetected.length === 1 ? tx("1 detected folder is unmarked.") : tx`${unmarkedDetected.length} detected folders are unmarked.`}{" "}
)}
{ "Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index)." }
); }