Файли
openidea/src/features/project-tree/index.ts
T
yoo hyeong chan 0dfdbbc323 feat(gradle): link and unlink independent Gradle builds (#1413)
Opening a folder of independent Gradle builds selected only the first build. Link Gradle Projects… (pick a build's settings or build file) and Unlink on a build's root let an ordinary project window manage several independent builds without changing their scripts. The build model holds a list of Gradle builds; each keeps its own wrapper, working directory, environment, task routing and sync cache, keyed by the owning build directory. The links persist in the project's Gradle settings (linkedProjects); unlinking leaves files and saved run configurations alone. Existing includeBuild support is unchanged, and an explicitly linked build takes precedence over a composite that also includes it. Workspace windows keep Link/Unlink disabled.

Co-authored-by: gudcks0305 <[email protected]>
2026-11-05 11:23:39 +08:00

229 рядки
9.0 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/** Project view: the "project" tool window, its file operations and actions. */
import { onEditorEvent, openEditorPaths } from "@/editor/api";
import { commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { useBuildModel } from "@/platform/buildModel";
import { validateProjectStructure, type RootFolder } from "@/platform/languageSettings";
import { useProject } from "@/platform/project";
import { initProjectRoots, useProjectRoots } from "@/platform/projectRoots";
import { folderIconName } from "@/ui";
import {
localChangedPaths,
provideProjectViewSelection,
registerOverlay,
registerProjectViewRoot,
registerToolWindow,
registerUndoHandler,
useLocalChanges,
useProjectViewRoots,
} from "@/workbench/contributions";
import { registerProjectActions } from "./actions";
import { gradleModuleGroups } from "./moduleGroups";
import { FileRefactorDialogHost } from "./FileRefactorDialogs";
import { NewPopupHost } from "./NewPopup";
import { RefactoringPreviewHost } from "./RefactoringPreview";
import { ProjectHeaderActions, useProjectHeaderMenu, useProjectTitleMenu } from "./HeaderActions";
import { loadHomeDir } from "./home";
import { selectedNodes, undoLastFileOperation } from "./operations";
import { ProjectView } from "./ProjectView";
import {
applyFsChange,
dropOutlines,
initProjectTree,
reloadAll,
reloadLoadedDirs,
setChangedFiles,
setOpenFiles,
setViewMode,
useProjectTree,
} from "./store";
export function activate(): () => void {
loadHomeDir();
let unityTimer: ReturnType<typeof setTimeout> | undefined;
let unityGeneration = 0;
const sync = () => {
const p = useProject.getState().project;
const s = useProjectTree.getState();
if ((p?.root ?? null) !== s.root || p?.workspace?.projects !== (s.members ?? undefined))
initProjectTree(p?.root ?? null, p?.name ?? "", p?.workspace?.projects ?? null, p?.unity ?? null);
else if (p) {
useProjectTree.setState({ name: p.name, unity: p.unity ?? null });
if (p.unity && !s.unity && s.mode === "project") setViewMode("unity");
if (!p.unity && s.mode === "unity") setViewMode("project");
}
};
sync();
const unsubProject = useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) {
unityGeneration++;
clearTimeout(unityTimer);
}
if (s.project !== prev.project) sync();
});
// Unity regenerates these files while the IDE stays open. Refresh only metadata,
// retaining the current settings and ignoring replies from a previous project.
const unsubUnity = onIdeEvent("fsChanged", (e) => {
const project = useProject.getState().project;
if (!project || !isTauri()) return;
const relevant = [...e.created, ...e.modified, ...e.removed].some((p) => {
if (!p.startsWith(project.root + "/")) return false;
const rel = p.slice(project.root.length + 1);
return (
/^(Assets|Packages|ProjectSettings)(\/ProjectVersion\.txt)?$/.test(rel) ||
/^[^/]+\.(csproj|sln|slnx)$/i.test(rel)
);
});
if (!relevant) return;
const generation = ++unityGeneration;
clearTimeout(unityTimer);
unityTimer = setTimeout(async () => {
try {
const fresh = await commands.projectCurrent();
const current = useProject.getState().project;
if (generation !== unityGeneration || current?.root !== project.root || fresh?.root !== project.root) return;
useProject.setState({ project: { ...current, unity: fresh.unity } });
} catch {
// A project may have closed while the metadata request was in flight.
}
}, 150);
});
// Excluded folders / exclude globs change which entries are `ignored`.
const unsubExcludes = useProject.subscribe((s, prev) => {
const ex = (x: typeof s.settings) =>
JSON.stringify([
x?.files?.exclude,
x?.projectStructure?.excluded,
x?.projectStructure?.unmarked,
x?.projectStructure?.excludeFiles,
]);
if (s.settings !== prev.settings && prev.settings && ex(s.settings) !== ex(prev.settings)) void reloadAll();
});
// …and so do excluded roots detected from build files (`target`, `node_modules`, …).
const disposeRoots = initProjectRoots();
const detectedExcluded = (s: { root: string | null; detected: readonly RootFolder[] }) =>
JSON.stringify([s.root, s.detected.filter((r) => r.kind === "excluded").map((r) => r.path)]);
const unsubRoots = useProjectRoots.subscribe((s, prev) => {
if (s.root && s.root === prev.root && detectedExcluded(s) !== detectedExcluded(prev)) void reloadAll();
});
// Project Structure content roots after the project folder, and IDEA's module groups of
// a Gradle build with modules outside it (as of the last sync) before it.
let contentRoots: Array<() => void> = [];
let shownRoots = "";
const syncContentRoots = () => {
const { project, settings } = useProject.getState();
const own = project && !project.workspace;
const paths = own ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
// The Build tool window's model (one load per build change), not a load of our own.
const build = useBuildModel.getState();
const gradleBuilds = build.root === project?.root ? (build.model?.gradle ?? []) : [];
const groups =
own && project
? gradleBuilds.flatMap((g) =>
gradleModuleGroups(g.projects, g.projects.find((p) => p.path === ":")?.name ?? "", project.root, g.dir),
)
: [];
const key = JSON.stringify([paths, groups]);
if (key === shownRoots) return;
shownRoots = key;
contentRoots.forEach((d) => d());
contentRoots = paths.map((path, i) =>
registerProjectViewRoot({
id: `contentRoot:${path}`,
label: path.slice(path.lastIndexOf("/") + 1) || path,
detail: path,
icon: folderIconName("module"),
path,
order: -100 + i,
}),
);
groups.forEach((g, i) =>
contentRoots.push(registerProjectViewRoot({ ...g, id: g.id!, order: -200 + i, beforeProject: true })),
);
};
syncContentRoots();
const unsubGradleRoots = useBuildModel.subscribe((s, prev) => {
if (s.model !== prev.model || s.root !== prev.root) syncContentRoots();
});
const unsubContentRoots = useProject.subscribe((s, prev) => {
if (s.settings !== prev.settings || s.project !== prev.project) syncContentRoots();
});
// Contributed roots (Scratches) are part of the derived model: re-render.
const unsubExtraRoots = useProjectViewRoots.subscribe(() =>
useProjectTree.setState((s) => ({ listings: new Map(s.listings) })),
);
// Open Files / All Changed Files panes: editor tabs and VCS changes, via the editor API and decorations.
let openTimer: ReturnType<typeof setTimeout> | undefined;
const syncOpenFiles = () => {
clearTimeout(openTimer);
// Tabs update after the document events.
openTimer = setTimeout(() => setOpenFiles(openEditorPaths()), 0);
};
syncOpenFiles();
const unsubEditor = onEditorEvent((e) => {
if (e.type === "docOpened" || e.type === "docClosed" || e.type === "activeEditorChanged") syncOpenFiles();
if (e.type === "docSaved") dropOutlines([e.path]);
});
// "All Changed Files": change-list files still on disk (IDEA leaves out unversioned, ignored and deleted ones).
const syncChanged = () => setChangedFiles(localChangedPaths(useLocalChanges.getState().lists, null, true));
syncChanged();
const unsubDecorations = useLocalChanges.subscribe((s, prev) => {
if (s.lists !== prev.lists) syncChanged();
});
const disposers = [
() => {
clearTimeout(unityTimer);
unityGeneration++;
unsubUnity();
},
() => clearTimeout(openTimer),
unsubEditor,
unsubDecorations,
unsubProject,
unsubExtraRoots,
unsubExcludes,
unsubContentRoots,
unsubGradleRoots,
() => contentRoots.forEach((d) => d()),
disposeRoots,
unsubRoots,
registerToolWindow({
id: "project",
title: "Project",
icon: "toolwindow-project",
anchor: "left",
order: 0,
component: ProjectView,
headerActions: ProjectHeaderActions,
headerMenu: useProjectHeaderMenu,
titleMenu: useProjectTitleMenu,
}),
registerOverlay({ id: "project.fileRefactor", component: FileRefactorDialogHost }),
registerOverlay({ id: "project.refactoringPreview", component: RefactoringPreviewHost }),
registerOverlay({ id: "project.newPopup", component: NewPopupHost }),
registerProjectActions(),
// Edit › Undo while the Project view has focus.
registerUndoHandler(
(command, focused) =>
command === "undo" &&
focused.getAttribute("role") === "tree" &&
!!focused.closest("[data-project-view]") &&
undoLastFileOperation(),
),
provideProjectViewSelection(() =>
selectedNodes()
.filter((n) => !n.group)
.map((n) => n.path),
),
onIdeEvent("fsChanged", (e) => void applyFsChange(e)),
onIdeEvent("excludedDirsChanged", (e) => void reloadLoadedDirs(e.dirs)),
];
return () => {
disposers.reverse().forEach((d) => d());
initProjectTree(null);
};
}