diff --git a/frontend/bindings/net/http/index.ts b/frontend/bindings/net/http/index.ts new file mode 100644 index 0000000..c9d993a --- /dev/null +++ b/frontend/bindings/net/http/index.ts @@ -0,0 +1,4 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export * from "./models.js"; diff --git a/frontend/bindings/net/http/models.ts b/frontend/bindings/net/http/models.ts new file mode 100644 index 0000000..afc5e67 --- /dev/null +++ b/frontend/bindings/net/http/models.ts @@ -0,0 +1,14 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import {Create as $Create} from "@wailsio/runtime"; + +/** + * A Header represents the key-value pairs in an HTTP header. + * + * The keys should be in canonical form, as returned by + * [CanonicalHeaderKey]. + */ +export type Header = { [_: string]: string[] }; diff --git a/frontend/bindings/time/index.ts b/frontend/bindings/time/index.ts new file mode 100644 index 0000000..c9d993a --- /dev/null +++ b/frontend/bindings/time/index.ts @@ -0,0 +1,4 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export * from "./models.js"; diff --git a/frontend/bindings/time/models.ts b/frontend/bindings/time/models.ts new file mode 100644 index 0000000..6646738 --- /dev/null +++ b/frontend/bindings/time/models.ts @@ -0,0 +1,51 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import {Create as $Create} from "@wailsio/runtime"; + +/** + * A Time represents an instant in time with nanosecond precision. + * + * Programs using times should typically store and pass them as values, + * not pointers. That is, time variables and struct fields should be of + * type [time.Time], not *time.Time. + * + * A Time value can be used by multiple goroutines simultaneously except + * that the methods [Time.GobDecode], [Time.UnmarshalBinary], [Time.UnmarshalJSON] and + * [Time.UnmarshalText] are not concurrency-safe. + * + * Time instants can be compared using the [Time.Before], [Time.After], and [Time.Equal] methods. + * The [Time.Sub] method subtracts two instants, producing a [Duration]. + * The [Time.Add] method adds a Time and a Duration, producing a Time. + * + * The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC. + * As this time is unlikely to come up in practice, the [Time.IsZero] method gives + * a simple way of detecting a time that has not been initialized explicitly. + * + * Each time has an associated [Location]. The methods [Time.Local], [Time.UTC], and Time.In return a + * Time with a specific Location. Changing the Location of a Time value with + * these methods does not change the actual instant it represents, only the time + * zone in which to interpret it. + * + * Representations of a Time value saved by the [Time.GobEncode], [Time.MarshalBinary], [Time.AppendBinary], + * [Time.MarshalJSON], [Time.MarshalText] and [Time.AppendText] methods store the [Time.Location]'s offset, + * but not the location name. They therefore lose information about Daylight Saving Time. + * + * In addition to the required “wall clock” reading, a Time may contain an optional + * reading of the current process's monotonic clock, to provide additional precision + * for comparison or subtraction. + * See the “Monotonic Clocks” section in the package documentation for details. + * + * Note that the Go == operator compares not just the time instant but also the + * Location and the monotonic clock reading. Therefore, Time values should not + * be used as map or database keys without first guaranteeing that the + * identical Location has been set for all values, which can be achieved + * through use of the UTC or Local method, and that the monotonic clock reading + * has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u) + * to t == u, since t.Equal uses the most accurate comparison available and + * correctly handles the case when only one of its arguments has a monotonic + * clock reading. + */ +export type Time = any; diff --git a/frontend/bindings/voidraft/internal/services/httpclientservice.ts b/frontend/bindings/voidraft/internal/services/httpclientservice.ts new file mode 100644 index 0000000..0831c48 --- /dev/null +++ b/frontend/bindings/voidraft/internal/services/httpclientservice.ts @@ -0,0 +1,31 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * HttpClientService HTTP客户端服务 + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import {Call as $Call, Create as $Create} from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +/** + * ExecuteRequest 执行HTTP请求 + */ +export function ExecuteRequest(request: $models.HttpRequest | null): Promise<$models.HttpResponse | null> & { cancel(): void } { + let $resultPromise = $Call.ByID(3143343977, request) as any; + let $typingPromise = $resultPromise.then(($result: any) => { + return $$createType1($result); + }) as any; + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +// Private type creation functions +const $$createType0 = $models.HttpResponse.createFrom; +const $$createType1 = $Create.Nullable($$createType0); diff --git a/frontend/bindings/voidraft/internal/services/index.ts b/frontend/bindings/voidraft/internal/services/index.ts index c65bb2a..84245b4 100644 --- a/frontend/bindings/voidraft/internal/services/index.ts +++ b/frontend/bindings/voidraft/internal/services/index.ts @@ -8,6 +8,7 @@ import * as DialogService from "./dialogservice.js"; import * as DocumentService from "./documentservice.js"; import * as ExtensionService from "./extensionservice.js"; import * as HotkeyService from "./hotkeyservice.js"; +import * as HttpClientService from "./httpclientservice.js"; import * as KeyBindingService from "./keybindingservice.js"; import * as MigrationService from "./migrationservice.js"; import * as SelfUpdateService from "./selfupdateservice.js"; @@ -18,7 +19,6 @@ import * as ThemeService from "./themeservice.js"; import * as TranslationService from "./translationservice.js"; import * as TrayService from "./trayservice.js"; import * as WindowService from "./windowservice.js"; -import * as WindowSnapService from "./windowsnapservice.js"; export { BackupService, ConfigService, @@ -27,6 +27,7 @@ export { DocumentService, ExtensionService, HotkeyService, + HttpClientService, KeyBindingService, MigrationService, SelfUpdateService, @@ -36,8 +37,7 @@ export { ThemeService, TranslationService, TrayService, - WindowService, - WindowSnapService + WindowService }; export * from "./models.js"; diff --git a/frontend/bindings/voidraft/internal/services/models.ts b/frontend/bindings/voidraft/internal/services/models.ts index b8f9ac0..567069b 100644 --- a/frontend/bindings/voidraft/internal/services/models.ts +++ b/frontend/bindings/voidraft/internal/services/models.ts @@ -8,6 +8,114 @@ import {Create as $Create} from "@wailsio/runtime"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as http$0 from "../../../net/http/models.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as time$0 from "../../../time/models.js"; + +/** + * HttpRequest HTTP请求结构 + */ +export class HttpRequest { + "method": string; + "url": string; + "headers": { [_: string]: string }; + + /** + * json, formdata, urlencoded, text + */ + "bodyType"?: string; + "body"?: any; + + /** Creates a new HttpRequest instance. */ + constructor($$source: Partial = {}) { + if (!("method" in $$source)) { + this["method"] = ""; + } + if (!("url" in $$source)) { + this["url"] = ""; + } + if (!("headers" in $$source)) { + this["headers"] = {}; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new HttpRequest instance from a string or object. + */ + static createFrom($$source: any = {}): HttpRequest { + const $$createField2_0 = $$createType0; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("headers" in $$parsedSource) { + $$parsedSource["headers"] = $$createField2_0($$parsedSource["headers"]); + } + return new HttpRequest($$parsedSource as Partial); + } +} + +/** + * HttpResponse HTTP响应结构 + */ +export class HttpResponse { + /** + * 使用resp.Status()返回完整状态如"200 OK" + */ + "status": string; + + /** + * 响应时间(毫秒) + */ + "time": number; + + /** + * 请求大小 + */ + "requestSize": string; + "body": any; + "headers": http$0.Header; + "timestamp": time$0.Time; + "error"?: any; + + /** Creates a new HttpResponse instance. */ + constructor($$source: Partial = {}) { + if (!("status" in $$source)) { + this["status"] = ""; + } + if (!("time" in $$source)) { + this["time"] = 0; + } + if (!("requestSize" in $$source)) { + this["requestSize"] = ""; + } + if (!("body" in $$source)) { + this["body"] = null; + } + if (!("headers" in $$source)) { + this["headers"] = ({} as http$0.Header); + } + if (!("timestamp" in $$source)) { + this["timestamp"] = null; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new HttpResponse instance from a string or object. + */ + static createFrom($$source: any = {}): HttpResponse { + const $$createField4_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("headers" in $$parsedSource) { + $$parsedSource["headers"] = $$createField4_0($$parsedSource["headers"]); + } + return new HttpResponse($$parsedSource as Partial); + } +} /** * MemoryStats 内存统计信息 @@ -273,8 +381,8 @@ export class SystemInfo { * Creates a new SystemInfo instance from a string or object. */ static createFrom($$source: any = {}): SystemInfo { - const $$createField3_0 = $$createType1; - const $$createField4_0 = $$createType2; + const $$createField3_0 = $$createType5; + const $$createField4_0 = $$createType6; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("osInfo" in $$parsedSource) { $$parsedSource["osInfo"] = $$createField3_0($$parsedSource["osInfo"]); @@ -313,7 +421,7 @@ export class WindowInfo { * Creates a new WindowInfo instance from a string or object. */ static createFrom($$source: any = {}): WindowInfo { - const $$createField0_0 = $$createType4; + const $$createField0_0 = $$createType8; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("Window" in $$parsedSource) { $$parsedSource["Window"] = $$createField0_0($$parsedSource["Window"]); @@ -343,8 +451,17 @@ export class WindowSnapService { } // Private type creation functions -const $$createType0 = OSInfo.createFrom; -const $$createType1 = $Create.Nullable($$createType0); -const $$createType2 = $Create.Map($Create.Any, $Create.Any); -const $$createType3 = application$0.WebviewWindow.createFrom; -const $$createType4 = $Create.Nullable($$createType3); +const $$createType0 = $Create.Map($Create.Any, $Create.Any); +var $$createType1 = (function $$initCreateType1(...args): any { + if ($$createType1 === $$initCreateType1) { + $$createType1 = $$createType3; + } + return $$createType1(...args); +}); +const $$createType2 = $Create.Array($Create.Any); +const $$createType3 = $Create.Map($Create.Any, $$createType2); +const $$createType4 = OSInfo.createFrom; +const $$createType5 = $Create.Nullable($$createType4); +const $$createType6 = $Create.Map($Create.Any, $Create.Any); +const $$createType7 = application$0.WebviewWindow.createFrom; +const $$createType8 = $Create.Nullable($$createType7); diff --git a/frontend/bindings/voidraft/internal/services/windowsnapservice.ts b/frontend/bindings/voidraft/internal/services/windowsnapservice.ts deleted file mode 100644 index 34ebfea..0000000 --- a/frontend/bindings/voidraft/internal/services/windowsnapservice.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -/** - * WindowSnapService 窗口吸附服务 - * @module - */ - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import {Call as $Call, Create as $Create} from "@wailsio/runtime"; - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js"; - -/** - * Cleanup 清理资源 - */ -export function Cleanup(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(2155505498) as any; - return $resultPromise; -} - -/** - * GetCurrentThreshold 获取当前自适应阈值(用于调试或显示) - */ -export function GetCurrentThreshold(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(3176419026) as any; - return $resultPromise; -} - -/** - * OnWindowSnapConfigChanged 处理窗口吸附配置变更 - */ -export function OnWindowSnapConfigChanged(enabled: boolean): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(3794787039, enabled) as any; - return $resultPromise; -} - -/** - * RegisterWindow 注册需要吸附管理的窗口 - */ -export function RegisterWindow(documentID: number, window: application$0.WebviewWindow | null, title: string): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(1000222723, documentID, window, title) as any; - return $resultPromise; -} - -/** - * ServiceShutdown 实现服务关闭接口 - */ -export function ServiceShutdown(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(1172710495) as any; - return $resultPromise; -} - -/** - * ServiceStartup 服务启动时初始化 - */ -export function ServiceStartup(options: application$0.ServiceOptions): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(2456823262, options) as any; - return $resultPromise; -} - -/** - * SetSnapEnabled 设置是否启用窗口吸附 - */ -export function SetSnapEnabled(enabled: boolean): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(2280126835, enabled) as any; - return $resultPromise; -} - -/** - * UnregisterWindow 取消注册窗口 - */ -export function UnregisterWindow(documentID: number): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(2844230768, documentID) as any; - return $resultPromise; -} diff --git a/frontend/src/views/editor/extensions/httpclient/index.ts b/frontend/src/views/editor/extensions/httpclient/index.ts index 35dd1a5..294ba57 100644 --- a/frontend/src/views/editor/extensions/httpclient/index.ts +++ b/frontend/src/views/editor/extensions/httpclient/index.ts @@ -5,12 +5,14 @@ import {Extension} from '@codemirror/state'; import {httpRequestsField, httpRunButtonGutter, httpRunButtonTheme} from './widgets/run-gutter'; +import {responseCacheField} from "@/views/editor/extensions/httpclient/parser/response-inserter"; /** * 创建 HTTP Client 扩展 */ export function createHttpClientExtension(): Extension[] { return [ + responseCacheField, httpRequestsField, httpRunButtonGutter, httpRunButtonTheme, diff --git a/frontend/src/views/editor/extensions/httpclient/parser/response-inserter.ts b/frontend/src/views/editor/extensions/httpclient/parser/response-inserter.ts index 61f5f79..e9f7152 100644 --- a/frontend/src/views/editor/extensions/httpclient/parser/response-inserter.ts +++ b/frontend/src/views/editor/extensions/httpclient/parser/response-inserter.ts @@ -1,27 +1,33 @@ import { EditorView } from '@codemirror/view'; -import { EditorState, ChangeSpec } from '@codemirror/state'; -import { syntaxTree } from '@codemirror/language'; +import { EditorState, ChangeSpec, StateField } from '@codemirror/state'; +import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language'; import type { SyntaxNode } from '@lezer/common'; -import { blockState } from '../../codeblock/state'; +import { getNoteBlockFromPos } from '../../codeblock/state'; /** * 响应数据模型 */ export interface HttpResponse { - /** 状态码 */ - status: number; - - /** 状态文本 */ - statusText: string; + /** 状态码和状态文本,如"200 OK" */ + status: string; /** 响应时间(毫秒) */ time: number; + /** 请求大小 */ + requestSize?: string; + /** 响应体 */ body: any; + /** 响应头 */ + headers?: { [_: string]: string[] }; + /** 时间戳 */ timestamp?: Date; + + /** 错误信息 */ + error?: any; } /** @@ -34,6 +40,44 @@ const NODE_TYPES = { JSON_ARRAY: 'JsonArray', } as const; +/** + * 缓存接口 + */ +interface ParseCache { + version: number; + blockId: string; + requestPositions: Map; +} + +/** + * StateField用于缓存解析结果 + */ +const responseCacheField = StateField.define({ + create(): ParseCache { + return { + version: 0, + blockId: '', + requestPositions: new Map() + }; + }, + + update(cache, tr): ParseCache { + // 如果有文档变更,清空缓存 + if (tr.docChanged) { + return { + version: cache.version + 1, + blockId: '', + requestPositions: new Map() + }; + } + return cache; + } +}); + /** * 响应插入位置信息 */ @@ -55,98 +99,119 @@ export class HttpResponseInserter { constructor(private view: EditorView) {} /** - * 在请求后插入响应数据 + * 插入HTTP响应(优化版本) * @param requestPos 请求的起始位置 * @param response 响应数据 */ insertResponse(requestPos: number, response: HttpResponse): void { const state = this.view.state; + + // 检查语法树是否可用,避免阻塞UI + if (!syntaxTreeAvailable(state)) { + // 延迟执行,等待语法树可用 + setTimeout(() => { + if (syntaxTreeAvailable(this.view.state)) { + this.insertResponse(requestPos, response); + } + }, 10); + return; + } + const insertPos = this.findInsertPosition(state, requestPos); if (!insertPos) { - console.error('no insert position'); return; } // 生成响应文本 const responseText = this.formatResponse(response); - - // 创建变更 + + // 根据是否有旧响应决定插入内容 + const insertText = insertPos.hasOldResponse + ? responseText // 替换旧响应,不需要额外换行 + : `\n${responseText}`; // 新插入,需要换行分隔 + const changes: ChangeSpec = { from: insertPos.from, to: insertPos.to, - insert: responseText + insert: insertText }; - // 应用变更 this.view.dispatch({ changes, - // 将光标移动到插入内容的末尾 - selection: { anchor: insertPos.from + responseText.length }, + userEvent: 'http.response.insert', + // 保持光标在请求位置 + selection: { anchor: requestPos }, + // 滚动到插入位置 + scrollIntoView: true }); } - /** - * 查找插入位置 - * 规则: - * 1. 在当前请求后面 - * 2. 在下一个请求前面 - * 3. 如果已有响应(# Response 开头),删除旧响应 + * 查找插入位置(带缓存优化) */ private findInsertPosition(state: EditorState, requestPos: number): InsertPosition | null { - const tree = syntaxTree(state); - const blocks = state.field(blockState, false); - - if (!blocks) return null; - - // 找到当前 HTTP 块 - const currentBlock = blocks.find(block => - block.language.name === 'http' && - block.content.from <= requestPos && - requestPos <= block.content.to - ); - - if (!currentBlock) return null; - - const context = this.findInsertionContext( - tree, - state, - requestPos, - currentBlock.content.from, - currentBlock.content.to - ); - - if (!context.requestNode) return null; - - const requestEnd = context.requestNode.to; - - if (context.oldResponse) { - // 如果有旧响应,精确替换(从上一行的末尾到响应末尾) - const oldResponseStartLine = state.doc.lineAt(context.oldResponse.from); - const prevLineNum = oldResponseStartLine.number - 1; - - let deleteFrom = context.oldResponse.from; - if (prevLineNum >= 1) { - const prevLine = state.doc.line(prevLineNum); - deleteFrom = prevLine.to; // 从上一行的末尾开始删除 - } - - return { - from: deleteFrom, - to: context.oldResponse.to, - hasOldResponse: true - }; - } else { - // 如果没有旧响应,在请求后面插入 - const requestEndLine = state.doc.lineAt(requestEnd); - - // 在当前行末尾插入(formatResponse 会自动添加必要的换行) - return { - from: requestEndLine.to, - to: requestEndLine.to, - hasOldResponse: false - }; + // 获取当前代码块 + const blockInfo = getNoteBlockFromPos(state, requestPos); + if (!blockInfo) { + return null; } + + const blockFrom = blockInfo.range.from; + const blockTo = blockInfo.range.to; + const blockId = `${blockFrom}-${blockTo}`; // 使用位置作为唯一ID + + // 检查缓存 + const cache = state.field(responseCacheField, false); + if (cache && cache.blockId === blockId) { + const cachedResult = cache.requestPositions.get(requestPos); + if (cachedResult) { + // 使用缓存结果 + const { requestNode, nextRequestPos, oldResponse } = cachedResult; + if (requestNode) { + const insertFrom = oldResponse ? oldResponse.from : requestNode.to + 1; + const insertTo = oldResponse ? oldResponse.to : insertFrom; + return { + from: insertFrom, + to: insertTo, + hasOldResponse: !!oldResponse + }; + } + } + } + + // 缓存未命中,执行解析 + const tree = syntaxTree(state); + const context = this.findInsertionContext(tree, state, requestPos, blockFrom, blockTo); + + // 更新缓存 + if (cache) { + cache.blockId = blockId; + cache.requestPositions.set(requestPos, context); + } + + if (!context.requestNode) { + return null; + } + + // 计算插入位置 + let insertFrom: number; + let insertTo: number; + let hasOldResponse = false; + + if (context.oldResponse) { + // 有旧响应,替换 + insertFrom = context.oldResponse.from; + insertTo = context.oldResponse.to; + hasOldResponse = true; + } else { + // 没有旧响应,在请求后插入 + const requestEndLine = state.doc.lineAt(context.requestNode.to); + // 在请求行末尾插入,添加换行符分隔 + insertFrom = requestEndLine.to; + insertTo = insertFrom; + } + + return { from: insertFrom, to: insertTo, hasOldResponse }; } /** @@ -216,41 +281,47 @@ export class HttpResponseInserter { // 查找响应注释 if (!responseStartNode && forwardCursor.name === NODE_TYPES.LINE_COMMENT) { const commentText = state.doc.sliceString(forwardCursor.from, forwardCursor.to); - // 避免不必要的 trim + // 避免不必要的 trim,同时识别普通响应和错误响应 if (commentText.startsWith('# Response') || commentText.startsWith(' # Response')) { const startNode = forwardCursor.node; responseStartNode = startNode; foundResponse = true; - // 继续查找 JSON 和结束分隔线 - let nextNode = startNode.nextSibling; - while (nextNode && nextNode.from < (nextRequestPos || blockTo)) { - // 找到 JSON - if (nextNode.name === NODE_TYPES.JSON_OBJECT || nextNode.name === NODE_TYPES.JSON_ARRAY) { - responseEndPos = nextNode.to; - - // 查找结束分隔线 - let afterJson = nextNode.nextSibling; - while (afterJson && afterJson.from < (nextRequestPos || blockTo)) { - if (afterJson.name === NODE_TYPES.LINE_COMMENT) { - const text = state.doc.sliceString(afterJson.from, afterJson.to); - // 使用更快的正则匹配 - if (/^#?\s*-+$/.test(text)) { - responseEndPos = afterJson.to; - break; + // 检查是否为错误响应(只有一行) + if (commentText.includes('Error:')) { + // 错误响应只有一行,直接设置结束位置 + responseEndPos = startNode.to; + } else { + // 继续查找 JSON 和结束分隔线(正常响应) + let nextNode = startNode.nextSibling; + while (nextNode && nextNode.from < (nextRequestPos || blockTo)) { + // 找到 JSON + if (nextNode.name === NODE_TYPES.JSON_OBJECT || nextNode.name === NODE_TYPES.JSON_ARRAY) { + responseEndPos = nextNode.to; + + // 查找结束分隔线 + let afterJson = nextNode.nextSibling; + while (afterJson && afterJson.from < (nextRequestPos || blockTo)) { + if (afterJson.name === NODE_TYPES.LINE_COMMENT) { + const text = state.doc.sliceString(afterJson.from, afterJson.to); + // 使用更快的正则匹配 + if (/^#?\s*-+$/.test(text)) { + responseEndPos = afterJson.to; + break; + } } + afterJson = afterJson.nextSibling; } - afterJson = afterJson.nextSibling; + break; } - break; + + // 遇到下一个请求,停止 + if (nextNode.name === NODE_TYPES.REQUEST_STATEMENT) { + break; + } + + nextNode = nextNode.nextSibling; } - - // 遇到下一个请求,停止 - if (nextNode.name === NODE_TYPES.REQUEST_STATEMENT) { - break; - } - - nextNode = nextNode.nextSibling; } } } @@ -277,14 +348,22 @@ export class HttpResponseInserter { * 格式化响应数据 */ private formatResponse(response: HttpResponse): string { + // 如果有错误,使用最简洁的错误格式 + if (response.error) { + return `# Response Error: ${response.error}`; + } + // 正常响应格式 const timestamp = response.timestamp || new Date(); const dateStr = this.formatTimestamp(timestamp); - // 构建响应头行(不带分隔符) - const headerLine = `# Response ${response.status} ${response.statusText} ${response.time}ms ${dateStr}`; + let headerLine = `# Response ${response.status} ${response.time}ms`; + if (response.requestSize) { + headerLine += ` ${response.requestSize}`; + } + headerLine += ` ${dateStr}`; - // 完整的开头行(只有响应头,不带分隔符) - const header = `\n${headerLine}\n`; + // 完整的开头行(不添加前导换行符) + const header = `${headerLine}\n`; // 格式化响应体 let body: string; @@ -299,15 +378,15 @@ export class HttpResponseInserter { } } else if (response.body === null || response.body === undefined) { // 空响应(只有响应头和结束分隔线) - const endLine = `# ${'-'.repeat(headerLine.length - 2)}`; // 减去 "# " 的长度 + const endLine = `# ${'-'.repeat(Math.max(16, headerLine.length - 2))}`; // 最小16个字符 return header + endLine; } else { // 对象或数组 body = JSON.stringify(response.body, null, 2); } - // 结尾分隔线:和响应头行长度完全一致 - const endLine = `# ${'-'.repeat(headerLine.length - 2)}`; // 减去 "# " 的长度 + // 结尾分隔线:和响应头行长度一致,最小16个字符 + const endLine = `# ${'-'.repeat(Math.max(16, headerLine.length - 2))}`; return header + body + `\n${endLine}`; } @@ -335,3 +414,8 @@ export function insertHttpResponse(view: EditorView, requestPos: number, respons inserter.insertResponse(requestPos, response); } +/** + * 导出StateField用于扩展配置 + */ +export { responseCacheField }; + diff --git a/frontend/src/views/editor/extensions/httpclient/widgets/run-gutter.ts b/frontend/src/views/editor/extensions/httpclient/widgets/run-gutter.ts index 4916c3c..15fb610 100644 --- a/frontend/src/views/editor/extensions/httpclient/widgets/run-gutter.ts +++ b/frontend/src/views/editor/extensions/httpclient/widgets/run-gutter.ts @@ -5,6 +5,7 @@ import { blockState } from '../../codeblock/state'; import { parseHttpRequest, type HttpRequest } from '../parser/request-parser'; import { insertHttpResponse, type HttpResponse } from '../parser/response-inserter'; import { createDebounce } from '@/common/utils/debounce'; +import { ExecuteRequest } from '@/../bindings/voidraft/internal/services/httpclientservice'; /** * 语法树节点类型常量 @@ -156,34 +157,36 @@ class RunButtonMarker extends GutterMarker { this.setLoadingState(true); try { - console.log(`\n============ 执行 HTTP 请求 ============`); - console.log('行号:', this.lineNumber); - console.log('解析结果:', JSON.stringify(this.cachedRequest, null, 2)); + const response = await ExecuteRequest(this.cachedRequest); + if (!response) { + throw new Error('No response'); + } - // TODO: 调用后端 API 执行请求 - // 临时模拟网络延迟 - await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000)); - - // 临时模拟响应数据用于测试 - const mockResponse: HttpResponse = { - status: 200, - statusText: 'OK', - time: Math.floor(Math.random() * 500) + 50, // 50-550ms - body: { - code: 200, - message: "请求成功", - data: { - id: 1001, - timestamp: new Date().toISOString() - } - }, - timestamp: new Date() + // 转换后端响应为前端格式 + const httpResponse: HttpResponse = { + status: response.status, // 后端已返回完整状态如"200 OK" + time: response.time, + requestSize: response.requestSize, + body: response.body, + headers: response.headers, + timestamp: response.timestamp ? new Date(response.timestamp) : new Date(), + error: response.error }; // 插入响应数据 - insertHttpResponse(view, this.cachedRequest.position.from, mockResponse); + insertHttpResponse(view, this.cachedRequest.position.from, httpResponse); } catch (error) { - console.error('HTTP 请求执行失败:', error); + // 创建错误响应 + const errorResponse: HttpResponse = { + status: 'Request Failed', + time: 0, + body: `Error: ${error instanceof Error ? error.message : String(error)}`, + timestamp: new Date(), + error: error + }; + + // 插入错误响应 + insertHttpResponse(view, this.cachedRequest.position.from, errorResponse); } finally { this.setLoadingState(false); } diff --git a/frontend/src/views/editor/extensions/hyperlink/index.ts b/frontend/src/views/editor/extensions/hyperlink/index.ts index f54596c..3d7fdba 100644 --- a/frontend/src/views/editor/extensions/hyperlink/index.ts +++ b/frontend/src/views/editor/extensions/hyperlink/index.ts @@ -9,6 +9,7 @@ import { } from '@codemirror/view'; import { Extension, Range } from '@codemirror/state'; import * as runtime from "@wailsio/runtime"; +import { getNoteBlockFromPos } from '../codeblock/state'; const pathStr = ``; const defaultRegexp = /\b((?:https?|ftp):\/\/[^\s/$.?#].[^\s]*)\b/gi; @@ -53,6 +54,13 @@ function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptio const from = match.index; const to = from + match[0].length; + // 检查当前位置是否在 HTTP 代码块中 + const block = getNoteBlockFromPos(view.state, from); + if (block && block.language.name === 'http') { + // 如果在 HTTP 代码块中,跳过超链接装饰 + continue; + } + const linkMark = Decoration.mark({ class: 'cm-hyper-link-text', attributes: { @@ -84,6 +92,13 @@ const linkDecorator = ( new MatchDecorator({ regexp: regexp || defaultRegexp, decorate: (add, from, to, match, view) => { + // 检查当前位置是否在 HTTP 代码块中 + const block = getNoteBlockFromPos(view.state, from); + if (block && block.language.name === 'http') { + // 如果在 HTTP 代码块中,跳过超链接装饰 + return; + } + const url = match[0]; let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url; if (matchData && matchData[url]) { diff --git a/go.mod b/go.mod index a83e76c..2def586 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( golang.org/x/sys v0.37.0 golang.org/x/text v0.30.0 modernc.org/sqlite v1.39.1 + resty.dev/v3 v3.0.0-beta.3 ) require ( diff --git a/go.sum b/go.sum index 8d8df52..2b2d0b7 100644 --- a/go.sum +++ b/go.sum @@ -262,3 +262,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E= +resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4= diff --git a/internal/services/httpclient_service.go b/internal/services/httpclient_service.go new file mode 100644 index 0000000..1f70c4b --- /dev/null +++ b/internal/services/httpclient_service.go @@ -0,0 +1,164 @@ +package services + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/wailsapp/wails/v3/pkg/services/log" + "resty.dev/v3" +) + +// HttpClientService HTTP客户端服务 +type HttpClientService struct { + logger *log.LogService + client *resty.Client +} + +// HttpRequest HTTP请求结构 +type HttpRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + BodyType string `json:"bodyType,omitempty"` // json, formdata, urlencoded, text + Body interface{} `json:"body,omitempty"` +} + +// HttpResponse HTTP响应结构 +type HttpResponse struct { + Status string `json:"status"` // 使用resp.Status()返回完整状态如"200 OK" + Time int64 `json:"time"` // 响应时间(毫秒) + RequestSize string `json:"requestSize"` // 请求大小 + Body interface{} `json:"body"` + Headers http.Header `json:"headers"` + Timestamp time.Time `json:"timestamp"` + Error interface{} `json:"error,omitempty"` +} + +// NewHttpClientService 创建新的HTTP客户端服务实例 +func NewHttpClientService(logger *log.LogService) *HttpClientService { + client := resty.New(). + SetTimeout(30 * time.Second). + SetRetryCount(0). + EnableTrace(). + SetHeaders(map[string]string{ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Credentials": "true", + }) + + return &HttpClientService{ + logger: logger, + client: client, + } +} + +// ExecuteRequest 执行HTTP请求 +func (hcs *HttpClientService) ExecuteRequest(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + // 创建请求 + req := hcs.client.R().SetContext(ctx) + + // 设置请求头 + if request.Headers != nil { + req.SetHeaders(request.Headers) + } + + // 设置请求体 + if err := hcs.setRequestBody(req, request); err != nil { + return nil, fmt.Errorf("set request body failed: %w", err) + } + + // 执行请求 + resp, err := req.Execute(strings.ToUpper(request.Method), request.URL) + + // 构建响应对象 + return hcs.buildResponse(resp, err) +} + +// setRequestBody 设置请求体 +func (hcs *HttpClientService) setRequestBody(req *resty.Request, request *HttpRequest) error { + if request.Body == nil { + return nil + } + + switch strings.ToLower(request.BodyType) { + case "json": + req.SetHeader("Content-Type", "application/json") + req.SetBody(request.Body) + case "formdata": + if formData, ok := request.Body.(map[string]interface{}); ok { + for key, value := range formData { + req.SetFormData(map[string]string{key: fmt.Sprintf("%v", value)}) + } + } + case "urlencoded": + req.SetHeader("Content-Type", "application/x-www-form-urlencoded") + if formData, ok := request.Body.(map[string]interface{}); ok { + values := url.Values{} + for key, value := range formData { + values.Set(key, fmt.Sprintf("%v", value)) + } + req.SetBody(values.Encode()) + } + case "text": + req.SetHeader("Content-Type", "text/plain") + req.SetBody(fmt.Sprintf("%v", request.Body)) + default: + return fmt.Errorf("unsupported body type: %s", request.BodyType) + } + + return nil +} + +// buildResponse 构建响应对象 +func (hcs *HttpClientService) buildResponse(resp *resty.Response, err error) (*HttpResponse, error) { + httpResp := &HttpResponse{ + Timestamp: time.Now(), + } + + // 如果有错误,设置错误状态并返回 + if err != nil { + httpResp.Status = "Request Failed" + httpResp.Error = err.Error() + return httpResp, nil + } + + // 设置基本响应信息 + httpResp.Status = resp.Status() + httpResp.Time = resp.Duration().Milliseconds() + httpResp.RequestSize = hcs.formatBytes(resp.Size()) + + if errorData := resp.Error(); errorData != nil { + httpResp.Error = errorData + } + + // 设置响应头 + httpResp.Headers = resp.Header() + httpResp.Body = resp.String() + + return httpResp, nil +} + +// formatBytes 格式化字节大小 +func (hcs *HttpClientService) formatBytes(bytes int64) string { + if bytes < 0 { + return "0 B" + } + + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} diff --git a/internal/services/service_manager.go b/internal/services/service_manager.go index 6e906db..83241e9 100644 --- a/internal/services/service_manager.go +++ b/internal/services/service_manager.go @@ -31,6 +31,7 @@ type ServiceManager struct { notificationService *notifications.NotificationService testService *TestService // 测试服务(仅开发环境) BackupService *BackupService + httpClientService *HttpClientService // HTTP客户端服务 logger *log.LogService } @@ -102,6 +103,9 @@ func NewServiceManager() *ServiceManager { // 初始化备份服务 backupService := NewBackupService(configService, databaseService, logger) + // 初始化HTTP客户端服务 + httpClientService := NewHttpClientService(logger) + // 初始化测试服务(开发环境使用) testService := NewTestService(badgeService, notificationService, logger) @@ -142,7 +146,6 @@ func NewServiceManager() *ServiceManager { databaseService: databaseService, documentService: documentService, windowService: windowService, - windowSnapService: windowSnapService, migrationService: migrationService, systemService: systemService, hotkeyService: hotkeyService, @@ -158,6 +161,7 @@ func NewServiceManager() *ServiceManager { notificationService: notificationService, testService: testService, BackupService: backupService, + httpClientService: httpClientService, logger: logger, } } @@ -169,7 +173,6 @@ func (sm *ServiceManager) GetServices() []application.Service { application.NewService(sm.databaseService), application.NewService(sm.documentService), application.NewService(sm.windowService), - application.NewService(sm.windowSnapService), application.NewService(sm.keyBindingService), application.NewService(sm.extensionService), application.NewService(sm.migrationService), @@ -185,6 +188,7 @@ func (sm *ServiceManager) GetServices() []application.Service { application.NewService(sm.notificationService), application.NewService(sm.testService), application.NewService(sm.BackupService), + application.NewService(sm.httpClientService), } return services } @@ -273,3 +277,8 @@ func (sm *ServiceManager) GetNotificationService() *notifications.NotificationSe func (sm *ServiceManager) GetSystemService() *SystemService { return sm.systemService } + +// GetHttpClientService 获取HTTP客户端服务 +func (sm *ServiceManager) GetHttpClientService() *HttpClientService { + return sm.httpClientService +}