Compare commits

..
Author SHA1 Message Date
l0ng-ai 6f0a291810 fix(project-view): offer Git and Find Usages in workspace members outside the workspace folder
The Project view's context menu counted a node as part of the project only inside the window's root, which in a workspace is the workspace folder. Members that live outside it ("../../repos/a") lost the Git submenu and Find Usages, although their repositories are discovered and their changes listed. Those entries now accept nodes inside the root or any workspace member; Mark Directory as stays limited to the root, whose project structure it edits, and Scratches stay outside.

Fixes #3111
2026-11-13 18:50:18 +08:00
5 changed files with 50 additions and 40 deletions

No files matched your search

@@ -301,6 +301,32 @@ it("offers workspace removal for every member root", () => {
);
});
it("offers Git and Find Usages in workspace members outside the workspace folder", () => {
useProject.setState({
project: {
root: "/work/workspaces/demo",
name: "demo",
settings: {},
trusted: true,
isGit: false,
workspace: { version: 1, name: "demo", projects: ["/work/repos/a", "/work/repos/b"] },
},
});
setContext("vcsAvailable", true);
try {
for (const target of [node("/work/repos/a", true, true), node("/work/repos/a/src", true)])
expect(sub(contextMenuItems([target]), "Git")).toContain("Commit…");
expect(sub(contextMenuItems([node("/work/repos/a/x.py")]), "Git")).toContain("Annotate with Git Blame");
expect(labels(contextMenuItems([node("/work/repos/a/x.py")]))).toContain("Find Usages");
// Project structure is the workspace folder's: its roots can't name a folder outside it.
expect(labels(contextMenuItems([node("/work/repos/a/src", true)]))).not.toContain("Mark Directory as");
// Scratches stay outside.
expect(sub(contextMenuItems([node("/config/scratches/s.py")]), "Git")).toBeUndefined();
} finally {
clearContext("vcsAvailable");
}
});
it("does not offer removal on the attached primary", () => {
useProject.setState({
project: {
+5 -2
View File
@@ -151,6 +151,9 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
const project = useProject.getState().project;
// Scratches and other contributed roots live outside the project: no VCS, roots or LSP usages.
const inProject = !!project && nodes.every((n) => isSameOrInside(n.path, project.root));
// Workspace members may live outside the workspace folder; they have VCS and usages too.
const memberRoots = project ? [project.root, ...(project.workspace?.projects ?? [])] : [];
const inWorkspace = nodes.every((n) => memberRoots.some((root) => isSameOrInside(n.path, root)));
const items: MenuItem[] = [];
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
@@ -228,7 +231,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
shortcut: keyLabel("project.open"),
run: () => files.forEach((f) => void openFile(f.path)),
},
inProject &&
inWorkspace &&
singleFile && {
id: "findUsages",
label: "Find Usages",
@@ -338,7 +341,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
);
// Git › (`vcsAvailable` also covers repositories nested below a non-Git project root)
if (inProject && (project?.isGit || getContext().vcsAvailable === true)) {
if (inWorkspace && (project?.isGit || getContext().vcsAvailable === true)) {
const arg = vcsArg(nodes);
const dirs = nodes.some((n) => n.dir);
push(
@@ -43,23 +43,13 @@ export function AddInterpreterDialog({
const [error, setError] = useState<string | null>(null);
const ids = { base: useId(), location: useId(), existing: useId() };
const baseValue = base ?? bases[0]?.path ?? null;
const toOption = (s: { path: string; name: string }): SelectOption => ({
value: s.path,
label: s.name,
detail: tildify(s.path),
});
// conda installs a fresh Python of the base's version: offer versions, not environments to copy.
const versionOptions = (() => {
const seen = new Map<string, SelectOption>();
for (const s of bases) {
const short = s.version?.split(".").slice(0, 2).join(".");
if (short && !seen.has(short)) seen.set(short, { value: s.path, label: tx`Python ${short}` });
}
return [...seen.values()].sort((a, b) => b.label.localeCompare(a.label, undefined, { numeric: true }));
})();
const baseOptions = kind === "conda" ? versionOptions : bases.map(toOption);
const baseValue =
base ?? (kind === "conda" ? (versionOptions[0]?.value as string | undefined) : bases[0]?.path) ?? null;
const baseOptions = bases.map(toOption);
const existingOptions = [...browsed, ...list.filter((s) => !browsed.some((b) => b.path === s.path))].map((s) => ({
value: s.path,
label: `${s.name} — ${tildify(s.path)}`,
@@ -118,9 +108,7 @@ export function AddInterpreterDialog({
const canOk = !busy && (mode === "new" ? !!baseValue && !!location.trim() : !!existing?.trim());
const note = {
venv: tx("Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter."),
conda: tx(
"Runs “conda create -p <location> python=<version>”: a new environment with only Python in it; the new interpreter becomes the project interpreter.",
),
conda: tx("Runs “conda create -p <location>”; the new interpreter becomes the project interpreter."),
uv: tx("Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter."),
poetry: tx(
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.",
@@ -155,7 +143,6 @@ export function AddInterpreterDialog({
aria-current={kind === k.tool || undefined}
onClick={() => {
setKind(k.tool);
setBase(null);
setError(null);
}}
>
@@ -212,7 +199,9 @@ export function AddInterpreterDialog({
}}
/>
</div>
<label htmlFor={ids.base}>{kind === "conda" ? <T>Python version:</T> : <T>Base interpreter:</T>}</label>
<label htmlFor={ids.base}>
<T>Base interpreter:</T>
</label>
<div className={styles.pathRow}>
<Select
className={styles.pathInput}
@@ -220,19 +209,17 @@ export function AddInterpreterDialog({
options={baseOptions}
onChange={setBase}
placeholder={loading ? "Detecting…" : "No Python found"}
aria-label={kind === "conda" ? "Python version" : "Base interpreter"}
aria-label="Base interpreter"
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
onClick={async () => {
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
if (p) setBase(p);
}}
/>
{kind !== "conda" && (
<IconButton
icon="folder-open"
size="small"
title="Browse…"
onClick={async () => {
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
if (p) setBase(p);
}}
/>
)}
</div>
<div className={styles.note}>{note}</div>
</>
@@ -68,8 +68,6 @@ export function PythonInterpreterPage() {
const [activate, setActivate] = useSetting<boolean>("languages.python.activateVirtualenv");
const root = useProject((s) => s.project?.root ?? null);
const [auto, setAuto] = useState<string | null>(null);
// Bumped when an environment is created: it may reuse the configured path, whose state changed.
const [created, setCreated] = useState(0);
// Detected interpreters plus the ones added to (or hidden from) the IDE-wide list.
const { detected, rows, hidden } = usePythonInterpreters({ withProjects: false });
const extras = useMemo(
@@ -93,7 +91,7 @@ export function PythonInterpreterPage() {
return () => {
live = false;
};
}, [root, created]);
}, [root]);
// The interpreter runs resolve to: the configured one while it exists, else the fallback.
const [resolved, setResolved] = useState<{ interpreter: string; path: string | null } | null>(null);
@@ -106,7 +104,7 @@ export function PythonInterpreterPage() {
return () => {
live = false;
};
}, [root, interpreter, created]);
}, [root, interpreter]);
// A configured interpreter whose executable is gone (PyCharm's "Invalid"): runs fall back.
const invalid =
!!interpreter && resolved?.interpreter === interpreter && !sameInterpreter(resolved.path, interpreter, root);
@@ -364,8 +362,6 @@ export function PythonInterpreterPage() {
onClose={() => setAdding(false)}
onAdded={(path) => {
setInterpreter(path);
setCreated((n) => n + 1);
reload();
setAdding(false);
}}
/>
+1 -3
View File
@@ -5217,9 +5217,7 @@
"{count} modified action": "已修改 {count} 个操作",
"{count} modified actions": "已修改 {count} 个操作",
"Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter.": "运行“<base> -m venv <location>”;新的解释器将成为项目解释器。",
"Runs “conda create -p <location> python=<version>”: a new environment with only Python in it; the new interpreter becomes the project interpreter.": "运行“conda create -p <location> python=<version>”:新建一个只包含 Python 的环境;新的解释器将成为项目解释器。",
"Python version": "Python 版本",
"Python {short}": "Python {short}",
"Runs “conda create -p <location>”; the new interpreter becomes the project interpreter.": "运行“conda create -p <location>”;新的解释器将成为项目解释器。",
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.": "在项目中运行“poetry env use <base>”;Poetry 会将环境保存在其自己的缓存目录下。",
"Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter.": "运行“uv venv <location> --python <base>”;新的解释器将成为项目解释器。",
"{vendor} {version} is unpacked into a folder of its own in {location}, where JDK detection finds it.": "{vendor} {version} 将解压到 {location} 下它自己的文件夹中,JDK 检测会在那里找到它。",