Files
openidea/src/features/project-tree/operations.test.ts
T
b0f65769c0 feat(remote): open WSL projects through a WSL transport (#989)
* perf(vfs): list directories off the main thread and reuse the repository lookup

Opening a folder on a network share (\\wsl.localhost\<distro>\...) is slow
because every listing ran on the main thread and walked up to the repository
root and ran a repository discovery again.

- fs_read_dir runs on the blocking pool.
- GitignoreStack::cached keeps the repository root and the repo-level rules
  for a few seconds. An edit of .git/info/exclude is still seen at once (its
  mtime and size are checked) and every .gitignore / .ignore is read fresh.

Refs #916

* feat(remote): open WSL projects through a WSL transport

A folder of a WSL distro (\\wsl.localhost\<distro>\... or \\wsl$\...) now opens
as a remote project whose openidea-server runs inside the distro, so the
terminal, SDK discovery, run, language servers and git are native Linux.

- RemoteTarget (ssh | wsl) replaces the SSH configuration id in the remote
  launch, connection, session and recents; old recents are read as SSH and a
  WSL target never inherits the legacy trust migration.
- remote_wsl: probe the distro, stream the server into ~/.openidea/server and
  start it with `wsl.exe -d <distro> --exec <bin> --stdio`. Distro names are
  validated and every command goes through --exec with positional arguments.
- wsl_distros / wsl_resolve commands; File > Open, the CLI and recents route
  WSL folders to the distro and focus the window that already has them.
- Remote Development dialog: SSH/WSL choice with the detected distros; the
  status bar shows `WSL: <distro>` / `SSH: <host>`.
- ide-jsonrpc: skip the NUL left at the start of a line by UTF-16 output of
  wsl.exe, which made the first message of the server disappear.

Not run on Windows yet: only a stand-in for wsl.exe is tested.

Refs #925, #327, #721

* fix(remote): satisfy clippy in WSL output decoding

* style(run): format run types

* fix: regenerate bindings and drop untranslated WSL catalog entry

* test(i18n): list WSL as an untranslated product name

* fix(run): pick build wrappers and shell quoting by the execution host

A remote window runs commands on its Linux or macOS server, so a Windows
desktop no longer selects gradlew.bat, mvnw.cmd or Windows quoting for it.

* fix(welcome): open legacy WSL UNC recents and sessions through the distro

Recents and the restored session called the local openProject for a
\\wsl.localhost folder saved before WSL projects, which fails with OS
error 123. They now resolve it like the other entry points.

* fix(windows): keep WSL files out of remote project launches

path_launch tested the WSL share before is_dir, so a first-instance CLI
launch with a WSL file opened a remote project instead of LightEdit.

* fix(remote): run-command OS comes from the remote host, not an assumption

The server's OS was dropped after connect, so a restored session had none and
isWindows() assumed every remote host is Unix. Keep it on the connection and
the session, and resolve it through hostOs().

---------

Co-authored-by: NguyenBang <nguyenbang@NTBANG.local>
Co-authored-by: NguyenBang <nguyenbang@Mac.lan>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-11-05 15:37:19 +08:00

455 lines
17 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MEMBER_PREFIX, type ProjectNode } from "./model";
import {
canPaste,
checkEditingName,
copySelection,
importExternal,
paste,
refreshOsClipboard,
selectionAfterDelete,
transfer,
topLevelPaths,
} from "./operations";
import { IpcError } from "@/platform/ipc";
import { closeFileRefactorDialog, pendingFileRefactorDialog } from "./FileRefactorDialogs";
import { useRemote } from "@/platform/remote";
import { usePlatform } from "@/platform/platformInfo";
import { provideNotificationService } from "@/workbench/contributions";
import { MemoryFs, setProjectFsForTests } from "./fs";
import { initProjectTree, loadDir, setClipboard, setSelection, useProjectTree } from "./store";
import { setOsClipboardForTests } from "./clipboard";
let fs: MemoryFs;
const notify = vi.fn();
describe("file names follow the execution host", () => {
const platform = usePlatform.getState();
afterEach(() => {
usePlatform.setState(platform);
useRemote.setState({ session: null });
setProjectFsForTests(null);
initProjectTree(null);
});
it.each(["linux", "windows", "macos"])("validates names on a %s host from a Windows desktop", async (os) => {
usePlatform.setState({ os: "windows" });
useRemote.setState({
session: { target: { kind: "ssh", config: "host" }, root: "/p", host: "host", os, commands: [] },
});
fs = new MemoryFs({ "/p/readme.txt": "" }, "/p");
setProjectFsForTests(fs, "/p");
initProjectTree("/p", "p");
await loadDir("/p");
useProjectTree.setState({ editing: { kind: "new", parent: "/p", nodeKind: "file" } });
expect(checkEditingName("con").error !== null).toBe(os === "windows");
expect(checkEditingName("README.txt").error !== null).toBe(os !== "linux");
});
});
const node = (path: string, dir = false): ProjectNode => ({
key: path,
path,
chain: [path],
label: path.slice(path.lastIndexOf("/") + 1),
dir,
root: path === "/p",
entry: null,
ignored: false,
excluded: false,
symlink: false,
});
describe("selectionAfterDelete", () => {
const rows = [node("/p", true), node("/p/a", true), node("/p/a/x.ts"), node("/p/b", true), node("/p/c.md")];
it("selects the next surviving row, skipping the deleted folder's children", () => {
expect(selectionAfterDelete(rows, ["/p/a"])).toBe("/p/b");
});
it("falls back to the previous row at the end of the list", () => {
expect(selectionAfterDelete(rows, ["/p/c.md"])).toBe("/p/b");
expect(selectionAfterDelete(rows, ["/p/b", "/p/c.md"])).toBe("/p/a/x.ts");
});
it("returns null when nothing visible was deleted", () => {
expect(selectionAfterDelete(rows, ["/elsewhere"])).toBeNull();
});
});
describe("Unity asset selections", () => {
beforeEach(async () => {
fs = new MemoryFs(
{
"/p/Assets/Player.cs": "code",
"/p/Assets/Player.cs.meta": "metadata",
"/p/Assets/Player copy.cs.meta": "orphan",
},
"/p",
);
setProjectFsForTests(fs, "/p");
initProjectTree("/p", "Game", null, { editorVersion: "6000.0.1f1", projects: [], solutions: [] });
provideNotificationService({ notify });
notify.mockClear();
await loadDir("/p/Assets");
});
afterEach(() => {
setProjectFsForTests(null);
initProjectTree(null);
});
it("treats an asset and its selected metadata as one operation", () => {
expect(topLevelPaths([node("/p/Assets/Player.cs"), node("/p/Assets/Player.cs.meta")])).toEqual([
"/p/Assets/Player.cs",
]);
expect(
topLevelPaths([
node("/p/Assets/Group", true),
node("/p/Assets/Group.meta"),
node("/p/Assets/Group/Inner.cs.meta"),
]),
).toEqual(["/p/Assets/Group"]);
expect(topLevelPaths([node("/p/Assets/Player.cs.meta")])).toEqual(["/p/Assets/Player.cs.meta"]);
});
it("skips orphan metadata names when choosing a duplicate asset name", async () => {
const copy = vi.spyOn(fs, "copy");
await transfer(["/p/Assets/Player.cs", "/p/Assets/Player.cs.meta"], "/p/Assets", "copy");
expect(copy).toHaveBeenCalledTimes(1);
expect(copy).toHaveBeenCalledWith("/p/Assets/Player.cs", "/p/Assets/Player copy 2.cs");
});
});
// Local OS paths and remote project paths may be identical strings.
describe("OS file imports", () => {
beforeEach(async () => {
fs = new MemoryFs({ "/p/target": null, "/p/local.txt": "client" }, "/p");
setProjectFsForTests(fs, "/p");
initProjectTree("/p", "p");
await loadDir("/p");
useRemote.setState({
session: { target: { kind: "ssh", config: "ssh" }, root: "/p", host: "host", os: "linux", commands: ["fs_copy"] },
});
provideNotificationService({ notify });
notify.mockClear();
});
afterEach(() => {
useRemote.setState({ session: null });
setProjectFsForTests(null);
setOsClipboardForTests(null);
});
it("does not paste into the project root when a member row is selected", async () => {
const files = vi.fn(async () => ["/outside/local.txt"]);
setOsClipboardForTests({ files, hasFiles: async () => true, setFiles: async () => false });
const imported = vi.spyOn(fs, "importLocal");
const key = `${MEMBER_PREFIX}/p/local.txt\0function:main`;
setSelection(new Set([key]), key);
await paste();
expect(files).not.toHaveBeenCalled();
expect(imported).not.toHaveBeenCalled();
expect(pendingFileRefactorDialog()).toBeNull();
});
it.each(["paste", "drop"])(
"%s imports a client path under the remote root and keeps conflict names",
async (entry) => {
setOsClipboardForTests({
files: async () => ["/p/local.txt"],
hasFiles: async () => true,
setFiles: async () => false,
});
const imported = vi.spyOn(fs, "importLocal");
for (let i = 0; i < 2; i++) {
if (entry === "paste") await paste("/p/target");
else await importExternal(["/p/local.txt"], "/p/target");
}
expect(imported.mock.calls).toEqual([
["/p/local.txt", "/p/target/local.txt", expect.anything()],
["/p/local.txt", "/p/target/local copy.txt", expect.anything()],
]);
expect(notify).not.toHaveBeenCalled();
},
);
it("reports upload failure and continues with the next file", async () => {
const imported = vi.spyOn(fs, "importLocal").mockRejectedValueOnce(new Error("SSH disconnected"));
await importExternal(["/missing.txt", "/p/local.txt"], "/p/target");
expect(imported).toHaveBeenCalledTimes(2);
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "Some items could not be copied", body: "missing.txt: SSH disconnected" }),
);
expect(fs.nodes.has("/p/target/local.txt")).toBe(true);
});
it("reports a clipboard read failure", async () => {
setOsClipboardForTests({
files: async () => {
throw new Error("clipboard unavailable");
},
hasFiles: async () => true,
setFiles: async () => false,
});
await paste("/p/target");
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "Cannot access the clipboard", body: "clipboard unavailable" }),
);
});
it.each([false, true])("falls back to internal clipboard (cut=%s) when another app holds it", async (cut) => {
setOsClipboardForTests({
files: async () => {
throw new Error("busy");
},
hasFiles: async () => false,
setFiles: async () => false,
});
setClipboard({ paths: ["/p/local.txt"], cut });
await paste("/p/target");
expect(pendingFileRefactorDialog()).toEqual(
expect.objectContaining({ kind: cut ? "move" : "copy", paths: ["/p/local.txt"] }),
);
expect(notify).not.toHaveBeenCalled();
});
it("stops a multi-path import quietly on cancellation", async () => {
const imported = vi
.spyOn(fs, "importLocal")
.mockRejectedValueOnce(new IpcError({ kind: "cancelled", message: "Cancelled" }));
await importExternal(["/p/local.txt", "/p/another.txt"], "/p/target");
expect(imported).toHaveBeenCalledTimes(1);
expect(notify).not.toHaveBeenCalled();
});
it("reports skipped entries while keeping the imported folder", async () => {
vi.spyOn(fs, "importLocal").mockResolvedValueOnce(["bundle/pipe: skipped special file"]);
expect(await transfer(["/bundle"], "/p/target", "copy", undefined, "local")).toBe("/p/target/bundle");
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ body: "bundle/pipe: skipped special file" }));
});
it("keeps internal remote copies on the project filesystem", async () => {
const imported = vi.spyOn(fs, "importLocal");
const copied = vi.spyOn(fs, "copy");
await transfer(["/p/local.txt"], "/p/target", "copy");
expect(copied).toHaveBeenCalledWith("/p/local.txt", "/p/target/local.txt");
expect(imported).not.toHaveBeenCalled();
});
});
describe("project clipboard ownership", () => {
beforeEach(async () => {
fs = new MemoryFs({ "/p/target": null, "/p/source.txt": "source", "/other.txt": "other" }, "/p");
setProjectFsForTests(fs, "/p");
initProjectTree("/p", "p");
await loadDir("/p");
setSelection(new Set(["/p/source.txt"]), "/p/source.txt");
closeFileRefactorDialog();
});
afterEach(() => {
vi.unstubAllGlobals();
useRemote.setState({ session: null });
setProjectFsForTests(null);
setOsClipboardForTests(null);
closeFileRefactorDialog();
});
const cases = [false, true].flatMap((handedOff) =>
["same", "other", "empty", "error"].flatMap((osList) =>
[false, true].flatMap((cut) => ["focus", "paste"].map((entry) => ({ handedOff, osList, cut, entry }))),
),
);
it.each(cases)("hand-off=$handedOff OS=$osList cut=$cut on $entry", async ({ handedOff, osList, cut, entry }) => {
const files = vi.fn(async () => {
if (osList === "error") throw new Error("busy");
return osList === "same" ? ["/p/source.txt"] : osList === "other" ? ["/other.txt"] : [];
});
const setFiles = vi.fn(async () => handedOff);
setOsClipboardForTests({ files, hasFiles: async () => osList === "same" || osList === "other", setFiles });
copySelection(cut);
const clipboard = useProjectTree.getState().clipboard;
expect(clipboard).toEqual(expect.objectContaining({ paths: ["/p/source.txt"], cut }));
expect(setFiles).toHaveBeenCalledWith(["/p/source.txt"]);
const stale = handedOff && (osList === "other" || osList === "empty");
if (entry === "focus") await refreshOsClipboard();
else await paste("/p/target");
expect(useProjectTree.getState().clipboard).toBe(stale ? null : clipboard);
expect(canPaste()).toBe(!(handedOff && osList === "empty"));
// canPaste only consults cached state, even when called repeatedly by menus.
canPaste();
expect(files).toHaveBeenCalledTimes(1);
if (entry === "paste") {
if (osList === "other") {
expect(fs.nodes.has("/p/target/other.txt")).toBe(true);
expect(fs.nodes.has("/p/target/source.txt")).toBe(false);
expect(pendingFileRefactorDialog()).toBeNull();
} else if (stale) {
expect(pendingFileRefactorDialog()).toBeNull();
expect(fs.nodes.has("/p/target/source.txt")).toBe(false);
} else {
expect(pendingFileRefactorDialog()).toEqual(
expect.objectContaining({ kind: cut ? "move" : "copy", paths: ["/p/source.txt"] }),
);
}
}
});
it.each([false, true])("keeps the internal clipboard after a rejected hand-off (cut=%s)", async (cut) => {
setOsClipboardForTests({
files: async () => [],
hasFiles: async () => false,
setFiles: async () => {
throw new Error("unavailable");
},
});
copySelection(cut);
await refreshOsClipboard();
await paste("/p/target");
expect(pendingFileRefactorDialog()?.kind).toBe(cut ? "move" : "copy");
});
const remoteCases = [false, true].flatMap((handedOff) =>
["same", "other", "empty", "error"].flatMap((osList) =>
[false, true].flatMap((cut) => ["focus", "paste"].map((entry) => ({ handedOff, osList, cut, entry }))),
),
);
it.each(remoteCases)(
"remote hand-off=$handedOff OS=$osList cut=$cut on $entry",
async ({ handedOff, osList, cut, entry }) => {
useRemote.setState({
session: {
target: { kind: "ssh", config: "ssh" },
root: "/p",
host: "host",
os: "linux",
commands: ["fs_copy"],
},
});
const writeText = vi.fn(async () => {
if (!handedOff) throw new Error("unavailable");
});
vi.stubGlobal("navigator", { clipboard: { writeText } });
const setFiles = vi.fn(async () => true);
const files = vi.fn(async () => {
if (osList === "error") throw new Error("busy");
return osList === "same" ? ["/p/source.txt"] : osList === "other" ? ["/other.txt"] : [];
});
setOsClipboardForTests({ files, hasFiles: async () => false, setFiles });
copySelection(cut);
const clipboard = useProjectTree.getState().clipboard;
const stale = handedOff && (osList === "same" || osList === "other");
const copied = vi.spyOn(fs, "copy");
const imported = vi.spyOn(fs, "importLocal").mockResolvedValue([]);
if (entry === "focus") await refreshOsClipboard();
else await paste("/p/target");
expect(writeText).toHaveBeenCalledWith("/p/source.txt");
expect(setFiles).not.toHaveBeenCalled();
expect(files).toHaveBeenCalledTimes(1);
expect(useProjectTree.getState().clipboard).toBe(stale ? null : clipboard);
expect(canPaste()).toBe(true);
if (entry === "paste") {
if (stale) {
expect(imported).toHaveBeenCalledWith(
osList === "same" ? "/p/source.txt" : "/other.txt",
osList === "same" ? "/p/target/source.txt" : "/p/target/other.txt",
expect.anything(),
);
expect(pendingFileRefactorDialog()).toBeNull();
expect(copied).not.toHaveBeenCalled();
} else {
const dialog = pendingFileRefactorDialog()!;
expect(dialog).toEqual(expect.objectContaining({ kind: cut ? "move" : "copy", paths: ["/p/source.txt"] }));
if (dialog.kind === "copy") {
await dialog.copy("/p/target", null, false);
expect(copied).toHaveBeenCalledWith("/p/source.txt", "/p/target/source.txt");
}
expect(imported).not.toHaveBeenCalled();
}
}
},
);
it("writes multiple remote paths as newline-joined text", async () => {
useRemote.setState({
session: { target: { kind: "ssh", config: "ssh" }, root: "/p", host: "host", os: "linux", commands: ["fs_copy"] },
});
await fs.createFile("/p/second.txt");
await loadDir("/p", true);
setSelection(new Set(["/p/source.txt", "/p/second.txt"]), "/p/source.txt");
const writeText = vi.fn(async () => {});
vi.stubGlobal("navigator", { clipboard: { writeText } });
copySelection(false);
expect(await useProjectTree.getState().clipboard?.osWrite).toBe(true);
expect(writeText).toHaveBeenCalledWith("/p/source.txt\n/p/second.txt");
});
it("waits for a pending remote text write before reading file URLs", async () => {
useRemote.setState({
session: { target: { kind: "ssh", config: "ssh" }, root: "/p", host: "host", os: "linux", commands: ["fs_copy"] },
});
let finish!: () => void;
vi.stubGlobal("navigator", {
clipboard: {
writeText: () =>
new Promise<void>((resolve) => {
finish = resolve;
}),
},
});
const files = vi.fn(async () => []);
setOsClipboardForTests({ files, hasFiles: async () => false, setFiles: async () => true });
copySelection(false);
const refreshing = refreshOsClipboard();
await Promise.resolve();
expect(files).not.toHaveBeenCalled();
finish();
await refreshing;
expect(useProjectTree.getState().clipboard?.remote).toBe(true);
});
it("waits for the hand-off before reading the OS clipboard", async () => {
let finish!: (written: boolean) => void;
const files = vi.fn(async () => ["/p/source.txt"]);
setOsClipboardForTests({
files,
hasFiles: async () => false,
setFiles: () =>
new Promise((r) => {
finish = r;
}),
});
copySelection(true);
const refreshing = refreshOsClipboard();
await Promise.resolve();
expect(files).not.toHaveBeenCalled();
finish(true);
await refreshing;
expect(useProjectTree.getState().clipboard?.cut).toBe(true);
});
it("ignores a stale read that completes after a newer Copy", async () => {
let finish!: (paths: string[]) => void;
setOsClipboardForTests({
files: () =>
new Promise((r) => {
finish = r;
}),
hasFiles: async () => true,
setFiles: async () => true,
});
copySelection(true);
const refreshing = refreshOsClipboard();
await vi.waitFor(() => expect(finish).toBeTypeOf("function"));
copySelection(false);
const newer = useProjectTree.getState().clipboard;
finish([]);
await refreshing;
expect(useProjectTree.getState().clipboard).toBe(newer);
expect(canPaste()).toBe(true);
});
});