From 9d1fe178947f0ea950fcb9e9d12cf431bca78303 Mon Sep 17 00:00:00 2001 From: kevinh <45650311+kevinh458@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:09:50 +0800 Subject: [PATCH] fix(http-client): find the generated request scratch on Windows (#356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 <45650311+kevinh458@users.noreply.github.com> 未在 Windows 实机验证。 --- src/editor/core/model/session.ts | 3 +- src/features/http-client/index.ts | 4 +- src/features/http-client/spring/editor.ts | 4 +- .../http-client/spring/generate.test.ts | 84 +++++++++++++++++++ src/features/scratch/scratches.ts | 7 +- src/platform/paths.test.ts | 24 ++++++ src/platform/paths.ts | 22 +++++ 7 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 src/features/http-client/spring/generate.test.ts create mode 100644 src/platform/paths.test.ts create mode 100644 src/platform/paths.ts diff --git a/src/editor/core/model/session.ts b/src/editor/core/model/session.ts index be5930b46..6f6534b09 100644 --- a/src/editor/core/model/session.ts +++ b/src/editor/core/model/session.ts @@ -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 { diff --git a/src/features/http-client/index.ts b/src/features/http-client/index.ts index 578ab673d..c4ec7c327 100644 --- a/src/features/http-client/index.ts +++ b/src/features/http-client/index.ts @@ -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) { diff --git a/src/features/http-client/spring/editor.ts b/src/features/http-client/spring/editor.ts index 2a5f1d92e..a594af1bd 100644 --- a/src/features/http-client/spring/editor.ts +++ b/src/features/http-client/spring/editor.ts @@ -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; }; diff --git a/src/features/http-client/spring/generate.test.ts b/src/features/http-client/spring/generate.test.ts new file mode 100644 index 000000000..111f72553 --- /dev/null +++ b/src/features/http-client/spring/generate.test.ts @@ -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(); + return { + ...m, + call: async (p: Promise) => 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"); + }); +}); diff --git a/src/features/scratch/scratches.ts b/src/features/scratch/scratches.ts index eae4d1ffc..864f5cfc6 100644 --- a/src/features/scratch/scratches.ts +++ b/src/features/scratch/scratches.ts @@ -4,6 +4,7 @@ * `scratch[_n].` 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))), diff --git a/src/platform/paths.test.ts b/src/platform/paths.test.ts new file mode 100644 index 000000000..e7b455a17 --- /dev/null +++ b/src/platform/paths.test.ts @@ -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`); + }); +}); diff --git a/src/platform/paths.ts b/src/platform/paths.ts new file mode 100644 index 000000000..6c5d9a943 --- /dev/null +++ b/src/platform/paths.ts @@ -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; +}