fix(http-client): find the generated request scratch on Windows (#356)

Generate Request in HTTP Client (and Generate Requests for Controller, and the gutter icon) left the scratch file empty on Windows with "Cannot open file …\ai.l0ng.openidea/scratches/generated-requests.http": the scratch path joined the native app data directory (backslashes) with a /-separated suffix, so the opened editor could not be found by its path. Scratch paths now normalise the app data directory to the UI's /-separated form through a shared ipcPath helper, used by scratches, the HTTP client and Spring request generation.

Co-authored-by: kevinh458 <[email protected]>

未在 Windows 实机验证。
This commit is contained in:
kevinh authored and GitHub committed 2026-10-30 15:09:50 +08:00
1 parent 9575500dd6
commit 9d1fe17894
7 files changed
+144 -4

No files matched your search

+2 -1
View File
@@ -7,6 +7,7 @@
* plain JSON (localStorage, keyed by project root) and rebuilt into a fresh
* TabsState with new ids.
*/
import { ipcPath } from "@/platform/paths";
import type { EditorInput } from "@/workbench/contributions";
import { initialTabsState, paneIds, type LayoutNode, type Pane, type Tab, type TabsState } from "./tabs";
@@ -91,7 +92,7 @@ export function serializeSession(
* and `\\?\C:\a\b` become `C:/a/b`. Other paths are returned as is.
*/
export function slashPath(p: string): string {
return /^(\\\\\?\\)?[A-Za-z]:\\/.test(p) ? p.replace(/^\\\\\?\\/, "").replace(/\\/g, "/") : p;
return ipcPath(p);
}
function isSessionTab(v: unknown): v is SessionTab {
+3 -1
View File
@@ -8,6 +8,7 @@
import { activeEditor, registerEditorContribution, registerLanguageMode } from "@/editor/api";
import { executeAction, getAction, registerActions } from "@/platform/actions";
import { call, commands, emitLocalIdeEvent, IpcError, isTauri } from "@/platform/ipc";
import { ipcPath } from "@/platform/paths";
import { useProject } from "@/platform/project";
import {
editorService,
@@ -84,7 +85,8 @@ function directoryArg(arg: unknown): string | null {
async function newRequest(directory?: string) {
try {
const { appDataDir } = await import("@tauri-apps/api/path");
const dir = directory ?? `${(await appDataDir()).replace(/\/+$/, "")}/scratches`;
// `appDataDir()` is native (`C:\…`): the UI holds paths `/`-separated.
const dir = directory ?? `${ipcPath(await appDataDir()).replace(/\/+$/, "")}/scratches`;
try {
await call(commands.fsCreateDir(dir));
} catch (e) {
+3 -1
View File
@@ -16,6 +16,7 @@ import {
} from "@/editor/api";
import { tx } from "@/platform/i18n";
import { call, commands, emitLocalIdeEvent, IpcError } from "@/platform/ipc";
import { ipcPath } from "@/platform/paths";
import { useProject } from "@/platform/project";
import { isVirtualUri } from "@/platform/virtualDocs";
import { gutterIconMarkup, showContextMenu, type MenuItem } from "@/ui";
@@ -48,7 +49,8 @@ export interface Target {
async function scratchFile(): Promise<{ path: string; created: boolean }> {
const { appDataDir } = await import("@tauri-apps/api/path");
const dir = `${(await appDataDir()).replace(/[\\/]+$/, "")}/scratches`;
// `appDataDir()` is native (`C:\…`): the editor is found by its `/` path.
const dir = `${ipcPath(await appDataDir()).replace(/\/+$/, "")}/scratches`;
const ignoreExisting = (e: unknown) => {
if (!(e instanceof IpcError && e.kind === "alreadyExists")) throw e;
};
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { _addEditor, _removeEditor, type TextEditorHandle } from "@/editor/api";
import { provideEditorService, provideNotificationService } from "@/workbench/contributions";
import { springControllers } from "./endpoints";
import { generateRequests } from "./editor";
// `appDataDir()` is a native path: `\`-separated on Windows.
vi.mock("@tauri-apps/api/path", () => ({
appDataDir: async () => String.raw`C:\Users\me\AppData\Roaming\ai.l0ng.openidea`,
}));
// No backend here: the file commands answer like a project without Spring
// configuration files (empty configs, the files the body lookup probes missing).
vi.mock("@/platform/ipc", async (importOriginal) => {
const m = await importOriginal<typeof import("@/platform/ipc")>();
return {
...m,
call: async (p: Promise<unknown>) => await p,
commands: {
...m.commands,
fsCreateDir: async () => null,
fsCreateFile: async () => null,
fsReadText: async () => "",
},
};
});
const SCRATCH = "C:/Users/me/AppData/Roaming/ai.l0ng.openidea/scratches/generated-requests.http";
const JAVA = `package com.example.demo.web;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class DemoController {
@PostMapping("/h66")
public String h66() { return "ok"; }
}
`;
const notifications: { level: string; title: string; body?: unknown }[] = [];
function fakeEditor(path: string, dispatched: { changes?: { insert?: string } }[]): TextEditorHandle {
return {
view: {
state: { doc: { toString: () => "" } },
dispatch: (spec: { changes?: { insert?: string } }) => void dispatched.push(spec),
},
ctx: { doc: 1, path, language: "http", uiMode: null, readOnly: false },
} as unknown as TextEditorHandle;
}
describe("generate requests from a mapping", () => {
afterEach(() => {
notifications.length = 0;
});
it("opens the scratch at its UI path and appends the request", async () => {
const opened: string[] = [];
const dispatched: { changes?: { insert?: string } }[] = [];
provideEditorService({ openFile: async (path: string) => void opened.push(path) } as never);
provideNotificationService({
notify: (n: { level: string; title: string; body?: unknown }) => void notifications.push(n),
} as never);
const editor = fakeEditor(SCRATCH, dispatched);
_addEditor(editor);
try {
const controller = springControllers(JAVA)[0]!;
await generateRequests("D:/p/src/main/java/com/example/demo/web/DemoController.java", JAVA, [
{ endpoint: controller.endpoints[0]! },
]);
} finally {
_removeEditor(editor);
}
expect(notifications).toEqual([]);
// The scratch is opened — and found again by the editor registry — as the
// `C:/…` path every other path in the UI uses, not as a native `C:\…` one.
expect(opened).toEqual([SCRATCH]);
expect(dispatched[0]?.changes?.insert).toContain("### DemoController.h66");
expect(dispatched[0]?.changes?.insert).toContain("POST http://localhost:8080/api/h66");
});
});
+6 -1
View File
@@ -4,6 +4,7 @@
* `scratch[_n].<ext>` name and opens it as a normal document.
*/
import { IpcError, call, commands, emitLocalIdeEvent, isTauri } from "@/platform/ipc";
import { ipcPath } from "@/platform/paths";
import { editorService, notifications } from "@/workbench/contributions";
import { scratchFileName, scratchLanguages, type ScratchLanguage } from "./model";
@@ -18,7 +19,11 @@ export interface ScratchBackend {
}
export const tauriScratchBackend: ScratchBackend = {
dataDir: async () => (await import("@tauri-apps/api/path")).appDataDir(),
// `appDataDir()` is native (`C:\…`); the scratch paths are used as UI paths.
dataDir: async () => {
const { appDataDir } = await import("@tauri-apps/api/path");
return ipcPath(await appDataDir());
},
listNames: async (dir) => (await call(commands.fsReadDir(dir))).map((e) => e.name),
createDir: async (dir) => void (await call(commands.fsCreateDir(dir))),
createFile: async (path) => void (await call(commands.fsCreateFile(path))),
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { ipcPath } from "./paths";
const raw = String.raw;
const APPDATA = raw`C:\Users\me\AppData\Roaming\ai.l0ng.openidea`;
describe("ipcPath", () => {
it("slashes Windows drive paths, plain or verbatim", () => {
expect(ipcPath(APPDATA)).toBe("C:/Users/me/AppData/Roaming/ai.l0ng.openidea");
expect(ipcPath(raw`\\?\C:\Users\me`)).toBe("C:/Users/me");
});
it("slashes network shares", () => {
expect(ipcPath(raw`\\server\share\dir`)).toBe("//server/share/dir");
expect(ipcPath(raw`\\?\UNC\server\share\dir`)).toBe("//server/share/dir");
});
it("leaves UI paths and POSIX paths alone", () => {
expect(ipcPath("C:/Users/me")).toBe("C:/Users/me");
expect(ipcPath("/home/me/notes")).toBe("/home/me/notes");
// A backslash is a legal character in a POSIX file name.
expect(ipcPath(raw`/home/me/a\b.txt`)).toBe(raw`/home/me/a\b.txt`);
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* Paths coming *into* the UI from a native API (`appDataDir()` from
* `@tauri-apps/api/path`, the file dialogs) are native strings: `C:\a\b` on
* Windows. Everything the UI stores and compares — what the backend returns
* (`IpcPath` in `crates/ide-core/src/ipc_path.rs`), the editor's `ctx.path`, the
* project tree — is `/`-separated on every OS, so such a path has to be
* converted before it is used.
*/
const DRIVE = /^[A-Za-z]:\\/;
const NETWORK = /^\\\\/;
/**
* `p` in the form the UI holds paths: `C:\a\b` and `\\?\C:\a\b` become
* `C:/a/b`, `\\server\share` becomes `//server/share`. A path that already is
* in that form — or one that is not a Windows path at all — is returned as is.
*/
export function ipcPath(p: string): string {
let path = p;
if (path.startsWith("\\\\?\\")) path = path.startsWith("\\\\?\\UNC\\") ? `\\\\${path.slice(8)}` : path.slice(4);
return DRIVE.test(path) || NETWORK.test(path) ? path.replace(/\\/g, "/") : path;
}