mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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:
1 parent
9575500dd6
commit
9d1fe17894
7 files changed
+144
-4
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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))),
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user