From 4da8ea846ffa37d4386d6a3595fdfb4cb901e1f0 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 2 Nov 2026 01:25:23 -0800 Subject: [PATCH 1/2] fix: polish HTTP headers and configurable response previews --- CHANGELOG.md | 9 ++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 8 +++---- assets/http-tool.svg | 1 + plugin.json | 49 ++++++++++++++++++++++++++++++++++------ scripts/package.py | 1 + scripts/test_protocol.py | 24 ++++++++++++++++++-- scripts/test_ui.js | 11 ++++++++- src/main.rs | 36 +++++++++++++++++++++++------ ui/index.html | 4 ++-- ui/main.js | 36 +++++++++++++++++++++++++---- ui/style.css | 15 ++++++++++++ 13 files changed, 168 insertions(+), 30 deletions(-) create mode 100644 assets/http-tool.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 0460f03..d1987c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.1.1 — 2026-11-02 (preview) + +- Remove the duplicate HTTP Client title inside the native tool window. +- Highlight header keys, separate values and collapse long values until expanded. +- Configure body preview size in the panel: 48 / 256 / 512 KiB, default 512 KiB. Preserve Unicode and account for JSON escaping within the host transport budget. +- Hide JSON formatting and body truncation notices while viewing headers. +- Declare and package a dedicated theme-tinted HTTP tool-window icon. +- Marketplace installation progress is host-owned and is tracked separately from this plugin release. + ## 0.1.0 — 2026-11-02 (preview) - Independent HTTP Client tool window, preserving the native Lithe workbench. diff --git a/Cargo.lock b/Cargo.lock index b3e5e3b..c55fe58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -677,7 +677,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lithe-http-client" -version = "0.1.0" +version = "0.1.1" dependencies = [ "ide-core", "ide-http", diff --git a/Cargo.toml b/Cargo.toml index 5a10f8d..a3a8bcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lithe-http-client" -version = "0.1.0" +version = "0.1.1" edition = "2024" publish = false license = "Apache-2.0" diff --git a/README.md b/README.md index 55c693b..0f339fb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # HTTP Client for Lithe -**0.1.0 preview:** protocol/Core integration is verified; native install/window and Intel hardware acceptance are still pending. +**0.1.1 preview:** protocol/Core integration is verified; native install/window and Intel hardware acceptance are still pending. A small HTTP workbench inside Lithe's native macOS tool window. Paste `.http` requests, select one, send it, and inspect the response without leaving the IDE. @@ -23,14 +23,14 @@ Content-Type: application/json {"name":"example"} ``` -Replace the address with a service you intend to call. The UI follows the host's theme and language (English / Chinese), displays status, elapsed time and received size, and switches between body and headers. JSON formatting is optional. Select response text and use the normal copy shortcut. Responses are displayed as text; returned HTML is never executed. +Replace the address with a service you intend to call. The UI follows the host's theme and language (English / Chinese), displays status, elapsed time and received size, and switches between body and headers. JSON formatting is optional. Header names and values are separated; values start on one line and expand on click. The toolbar preview limit applies to the next send and is kept only for the current page session. JSON-escaped responses may be truncated earlier to fit the 900 KiB transport budget. Select response text and use the normal copy shortcut. Responses are displayed as text; returned HTML is never executed. ## Scope and data handling - The declared executable `bin/lithe-http-client` runs locally via the official Lithe plugin SDK. It performs HTTP(S) requests explicitly requested by the user. The Web UI only talks to the host bridge. - No request history, draft persistence or automatic workspace/file reads. Request text, headers and response content stay in memory; closing/recreating the page can discard them. The plugin does not write to its package or application bundle. - Inline `@variables`, headers, request bodies and `###` separators use the unchanged OpenIDEA parser/executor. Environment files, local body includes, response scripts and file redirects are unsupported. Includes and scripts/redirects fail explicitly before sending. -- 128 KiB input, 30-second overall request timeout, cancellable operations, 48 KiB text preview. Upstream retains at most 16 MiB of network body. Received size can exceed the preview size; truncation is visible. Binary responses show status/headers without a body preview. +- 128 KiB input, 30-second overall request timeout, cancellable operations, configurable 48 / 256 / 512 KiB text preview (512 KiB default). Upstream retains at most 16 MiB of network body. Received size can exceed the preview size; truncation is visible. Binary responses show status/headers without a body preview. - Network/prepare errors are localized stable codes; raw request URLs and transport errors are not exposed in diagnostics. User-supplied text and server responses can contain secrets; this is a local HTTP client, not a redaction tool. - Uses upstream default proxy behavior; Lithe's proxy settings are not synchronized. No custom TLS/credential/environment UI. - macOS host chrome remains native; the plugin content is a themed Web page. This is not a replacement for the editor or a new host architecture. @@ -49,7 +49,7 @@ python3 scripts/package.py python3 scripts/test_protocol.py --binary dist/io.github.lithe-idea.http-client/bin/lithe-http-client ``` -Use a consistent rustup `cargo`/`rustc` toolchain on PATH when cross-compiling. Packaging uses `lipo`, validates both architectures, signs the executable ad-hoc, checks its signature, includes dependency notices and creates `dist/http-client-0.1.0-darwin.zip` with one plugin-ID parent directory and a SHA-256 sidecar. Published release assets are immutable: fixes require a new version. +Use a consistent rustup `cargo`/`rustc` toolchain on PATH when cross-compiling. Packaging uses `lipo`, validates both architectures, signs the executable ad-hoc, checks its signature, includes dependency notices and creates `dist/http-client-0.1.1-darwin.zip` with one plugin-ID parent directory and a SHA-256 sidecar. Published release assets are immutable: fixes require a new version. Protocol integration tests use real SDK processes and a loopback HTTP server, with bounded waits and cleanup. Individual timings and failures are emitted to `.artifacts/test-stability/index.html` and `junit.xml`. diff --git a/assets/http-tool.svg b/assets/http-tool.svg new file mode 100644 index 0000000..1b5b47f --- /dev/null +++ b/assets/http-tool.svg @@ -0,0 +1 @@ + diff --git a/plugin.json b/plugin.json index bd2b526..c3938d9 100644 --- a/plugin.json +++ b/plugin.json @@ -2,13 +2,48 @@ "schemaVersion": 2, "id": "io.github.lithe-idea.http-client", "displayName": "HTTP Client", - "version": "0.1.0", + "version": "0.1.1", "pluginAPIVersion": 3, - "requires": { "backend": 1, "serverProcess": 1, "ui": 2 }, - "vendor": { "id": "io.github.lithe-idea", "displayName": "Lithe Community", "signatureRequirement": "none" }, - "permissions": ["backend.launch", "ui.contribute", "ui.web"], - "activationEvents": ["onView:io.github.lithe-idea.http-client.client"], + "requires": { + "backend": 1, + "serverProcess": 1, + "ui": 2 + }, + "vendor": { + "id": "io.github.lithe-idea", + "displayName": "Lithe Community", + "signatureRequirement": "none" + }, + "permissions": [ + "backend.launch", + "ui.contribute", + "ui.web" + ], + "activationEvents": [ + "onView:io.github.lithe-idea.http-client.client" + ], "languages": [], - "backend": { "launch": { "kind": "packaged", "path": "bin/lithe-http-client" }, "arguments": [] }, - "ui": { "toolWindows": [{ "id": "io.github.lithe-idea.http-client.client", "title": "HTTP Client", "placement": "bottom", "web": { "entry": "ui/index.html" } }] } + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/lithe-http-client" + }, + "arguments": [] + }, + "ui": { + "toolWindows": [ + { + "id": "io.github.lithe-idea.http-client.client", + "title": "HTTP Client", + "placement": "bottom", + "web": { + "entry": "ui/index.html" + }, + "icon": { + "path": "assets/http-tool.svg", + "mode": "template" + } + } + ] + } } diff --git a/scripts/package.py b/scripts/package.py index e932433..8830072 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -29,6 +29,7 @@ shutil.copy2(root / "plugin.json", package) shutil.copy2(root / "README.md", package) shutil.copytree(root / "ui", package / "ui") + shutil.copytree(root / "assets", package / "assets") licenses = package / "licenses" licenses.mkdir() records = [] diff --git a/scripts/test_protocol.py b/scripts/test_protocol.py index ae718dc..7da0493 100644 --- a/scripts/test_protocol.py +++ b/scripts/test_protocol.py @@ -91,7 +91,8 @@ def do_GET(self): if self.path == "/slow": self.server.entered.set() self.server.release.wait(timeout=8) - body = ("好🙂" * 20000).encode() if self.path == "/large" else b'{"ok":true}' + body = ("好🙂" * 100000).encode() if self.path == "/large" else b'{"ok":true}' + if self.path == "/escaped": body = b"\x01" * (512 * 1024) self.reply(body) def do_POST(self): @@ -177,12 +178,31 @@ def test_cancel_after_server_accepts_then_reuse_backend(self): self.assertEqual(self.execute(f"GET {self.url}/ok")["result"]["status"], 200) def test_unicode_response_preview_is_bounded(self): - reply = self.execute(f"GET {self.url}/large") + reply = self.host.answer(self.host.invoke("requests.execute", {"text": f"GET {self.url}/large", "index": 0, "previewLimitKiB": 48})) value = reply["result"] self.assertTrue(value["truncated"]) self.assertLessEqual(len(value["body"].encode()), 48 * 1024) self.assertNotIn("\ufffd", value["body"]) + def test_configurable_preview_and_default(self): + for limit in [256, 512]: + value = self.host.answer(self.host.invoke("requests.execute", {"text": f"GET {self.url}/large", "index": 0, "previewLimitKiB": limit}))["result"] + self.assertGreater(len(value["body"].encode()), (limit - 1) * 1024) + self.assertLessEqual(len(value["body"].encode()), limit * 1024) + self.assertTrue(value["truncated"]) + value = self.execute(f"GET {self.url}/large")["result"] + self.assertGreater(len(value["body"].encode()), 511 * 1024) + + def test_escaped_preview_respects_transport_budget(self): + value = self.execute(f"GET {self.url}/escaped")["result"] + self.assertTrue(value["truncated"]) + self.assertLessEqual(len(json.dumps(value, separators=(",", ":")).encode()), 900 * 1024) + + def test_invalid_preview_limit_is_rejected(self): + for limit in [0, 1024, "512", None, 48.5]: + reply = self.host.answer(self.host.invoke("requests.execute", {"text": f"GET {self.url}/ok", "index": 0, "previewLimitKiB": limit})) + self.assertIn("httpClient.invalidInput", json.dumps(reply)) + def test_no_state_files_written(self): self.assertEqual(self.execute(f"GET {self.url}/ok")["result"]["status"], 200) self.assertEqual(list(Path(self.temp.name).iterdir()), []) diff --git a/scripts/test_ui.js b/scripts/test_ui.js index 9aff7d9..235e36d 100644 --- a/scripts/test_ui.js +++ b/scripts/test_ui.js @@ -15,7 +15,16 @@ async () => { await run(true); assert(calls.map(x=>x[1].function).join() === "requests.parse,requests.execute", "Send must parse before executing"); assert(response.value.includes('\n "ok": true'), "Pretty JSON missing"); checks.push("parse-send-format"); - $("headers-tab").click(); assert(response.value.includes("content-type: application/json"), "Headers missing"); + $("headers-tab").click(); assert($("response-headers").textContent.includes("content-type"), "Headers missing"); + assert(response.hidden && document.querySelector(".pretty-label").hidden, "Body-only controls must be hidden"); + const details = $("response-headers").querySelector("details"); + assert(!details.open, "Headers must start collapsed"); + details.querySelector("summary").click(); assert(details.open, "Header cannot expand"); + assert(details.querySelector(".header-value").textContent === "application/json", "Full header lost"); + assert(calls.at(-1)[1].params.previewLimitKiB === 512, "Default preview size missing"); + $("preview-limit").value = "256"; + await run(true); assert(calls.at(-1)[1].params.previewLimitKiB === 256, "Preview setting not used"); + checks.push("header-disclosure-preview-setting"); $("body-tab").click(); $("pretty").checked=false; $("pretty").dispatchEvent(new Event("change")); assert(response.value === sample.body, "Raw body changed"); checks.push("headers-raw"); sample.body=''; diff --git a/src/main.rs b/src/main.rs index a2124ed..c2fe6bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,8 @@ use serde_json::json; use std::{sync::Arc, time::Duration}; const INPUT_LIMIT: usize = 128 * 1024; -const PREVIEW_LIMIT: usize = 48 * 1024; +const DEFAULT_PREVIEW_KIB: u64 = 512; +const RESULT_LIMIT: usize = 900 * 1024; fn invalid(code: &str) -> FunctionError { PluginError::new(code, code).into() @@ -29,6 +30,14 @@ fn main() -> Result<(), Box> { })?; backend.function("requests.execute", move |params, context| { context.check_cancelled()?; + let preview_kib = match params.get("previewLimitKiB") { + None => DEFAULT_PREVIEW_KIB, + Some(value) => value + .as_u64() + .filter(|n| [48, 256, 512].contains(n)) + .ok_or_else(|| invalid("httpClient.invalidInput"))?, + }; + let preview_limit = preview_kib as usize * 1024; let text = source(¶ms)?; let parsed = parse::parse(text); let index = params["index"] @@ -80,20 +89,33 @@ fn main() -> Result<(), Box> { // Upstream caps the network body; the plugin protocol has a smaller JSON // budget. Trim on a UTF-8 boundary, and never return request credentials. if let Some(body) = response.body.as_mut() - && body.len() > PREVIEW_LIMIT + && body.len() > preview_limit { - let mut end = PREVIEW_LIMIT; + let mut end = preview_limit; while !body.is_char_boundary(end) { end -= 1; } body.truncate(end); response.truncated = true; } - let value = serde_json::to_value(response)?; - if serde_json::to_vec(&value)?.len() > 512 * 1024 { - return Err(invalid("httpClient.responseTooLarge")); + // JSON escaping can expand text up to six times. Keep the result below + // the host's 1 MiB function-result budget, including response metadata. + loop { + let value = serde_json::to_value(&response)?; + let encoded_size = serde_json::to_vec(&value)?.len(); + if encoded_size <= RESULT_LIMIT { + return Ok(value); + } + let Some(body) = response.body.as_mut().filter(|body| !body.is_empty()) else { + return Err(invalid("httpClient.responseTooLarge")); + }; + let mut end = body.len().saturating_sub(encoded_size - RESULT_LIMIT); + while !body.is_char_boundary(end) { + end -= 1; + } + body.truncate(end); + response.truncated = true; } - Ok(value) })?; std::process::exit(backend.listen()); } diff --git a/ui/index.html b/ui/index.html index e313b42..ced0bfa 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1,13 +1,13 @@ HTTP Client -
HTTP Client⌘ / Ctrl ↵
+
⌘ / Ctrl ↵
-
+
diff --git a/ui/main.js b/ui/main.js index 68b5769..9978055 100644 --- a/ui/main.js +++ b/ui/main.js @@ -1,7 +1,7 @@ "use strict"; const messages = { - en: { source: "Request · .http", response: "Response", select: "Select request", parse: "Read requests", send: "Send", cancel: "Cancel", ready: "Ready", empty: "No requests found. Check the request line.", working: "Working…", cancelling: "Cancelling…", cancelled: "Cancelled", truncated: "Response preview truncated at 48 KiB or the upstream body limit.", binary: "Binary response — text preview unavailable.", bridge: "Open this plugin in Lithe to send requests.", failed: "Request failed. Try again.", body: "Body", headers: "Headers", pretty: "Format JSON", memory: "Not saved to disk", hint: "Send a request to inspect its response.", stale: "Request changed. Send will read it again.", "httpClient.invalidInput": "Invalid or oversized input (maximum 128 KiB).", "httpClient.fileIncludeUnsupported": "Local file includes are not supported. Paste the body instead.", "httpClient.handlerUnsupported": "Response scripts and file redirects are not supported. Remove them before sending.", "httpClient.prepareFailed": "Check request syntax and inline variables. Environment files are not loaded.", "httpClient.invalidScheme": "Only HTTP and HTTPS are supported.", "httpClient.timeout": "The request exceeded 30 seconds.", "httpClient.transportFailed": "Connection failed. Check the URL, network and certificate.", "httpClient.responseTooLarge": "Response metadata exceeds the plugin limit." }, - zh: { source: "请求 · .http", response: "响应", select: "选择请求", parse: "读取请求", send: "发送", cancel: "取消", ready: "就绪", empty: "未找到请求,请检查请求行。", working: "处理中…", cancelling: "正在取消…", cancelled: "已取消", truncated: "响应预览已在 48 KiB 或上游正文上限处截断。", binary: "二进制响应,无法预览文本。", bridge: "请在 Lithe 中打开此插件以发送请求。", failed: "请求失败,请重试。", body: "正文", headers: "响应头", pretty: "格式化 JSON", memory: "不保存到磁盘", hint: "发送请求后在此查看响应。", stale: "请求已修改,发送时会重新读取。", "httpClient.invalidInput": "输入无效或超过 128 KiB。", "httpClient.fileIncludeUnsupported": "暂不支持本地文件引用,请直接粘贴正文。", "httpClient.handlerUnsupported": "暂不支持响应脚本和文件重定向,请移除后再发送。", "httpClient.prepareFailed": "请检查请求语法与内联变量;不会加载环境文件。", "httpClient.invalidScheme": "仅支持 HTTP 和 HTTPS。", "httpClient.timeout": "请求超过 30 秒。", "httpClient.transportFailed": "连接失败,请检查地址、网络与证书。", "httpClient.responseTooLarge": "响应元数据超过插件限制。" } + en: { previewLimit: "Body preview", expandHeader: "Expand or collapse header value", source: "Request · .http", response: "Response", select: "Select request", parse: "Read requests", send: "Send", cancel: "Cancel", ready: "Ready", empty: "No requests found. Check the request line.", working: "Working…", cancelling: "Cancelling…", cancelled: "Cancelled", truncated: "Response preview truncated. Increase the preview limit and send again; escaped content may reach the transport limit earlier.", binary: "Binary response — text preview unavailable.", bridge: "Open this plugin in Lithe to send requests.", failed: "Request failed. Try again.", body: "Body", headers: "Headers", pretty: "Format JSON", memory: "Not saved to disk", hint: "Send a request to inspect its response.", stale: "Request changed. Send will read it again.", "httpClient.invalidInput": "Invalid or oversized input (maximum 128 KiB).", "httpClient.fileIncludeUnsupported": "Local file includes are not supported. Paste the body instead.", "httpClient.handlerUnsupported": "Response scripts and file redirects are not supported. Remove them before sending.", "httpClient.prepareFailed": "Check request syntax and inline variables. Environment files are not loaded.", "httpClient.invalidScheme": "Only HTTP and HTTPS are supported.", "httpClient.timeout": "The request exceeded 30 seconds.", "httpClient.transportFailed": "Connection failed. Check the URL, network and certificate.", "httpClient.responseTooLarge": "Response metadata exceeds the plugin limit." }, + zh: { previewLimit: "正文预览上限", expandHeader: "展开或收起响应头值", source: "请求 · .http", response: "响应", select: "选择请求", parse: "读取请求", send: "发送", cancel: "取消", ready: "就绪", empty: "未找到请求,请检查请求行。", working: "处理中…", cancelling: "正在取消…", cancelled: "已取消", truncated: "响应预览已截断。可调高预览上限后重新发送;含转义字符的内容可能先达到传输上限。", binary: "二进制响应,无法预览文本。", bridge: "请在 Lithe 中打开此插件以发送请求。", failed: "请求失败,请重试。", body: "正文", headers: "响应头", pretty: "格式化 JSON", memory: "不保存到磁盘", hint: "发送请求后在此查看响应。", stale: "请求已修改,发送时会重新读取。", "httpClient.invalidInput": "输入无效或超过 128 KiB。", "httpClient.fileIncludeUnsupported": "暂不支持本地文件引用,请直接粘贴正文。", "httpClient.handlerUnsupported": "暂不支持响应脚本和文件重定向,请移除后再发送。", "httpClient.prepareFailed": "请检查请求语法与内联变量;不会加载环境文件。", "httpClient.invalidScheme": "仅支持 HTTP 和 HTTPS。", "httpClient.timeout": "请求超过 30 秒。", "httpClient.transportFailed": "连接失败,请检查地址、网络与证书。", "httpClient.responseTooLarge": "响应元数据超过插件限制。" } }; const chinese = String(window.lithe?.context?.locale || navigator.language).startsWith("zh"); const t = key => (chinese ? messages.zh : messages.en)[key] || messages.en.failed; @@ -19,6 +19,7 @@ function controls() { parseButton.disabled = busy || !window.lithe || !source.value.trim(); send.disabled = busy || !window.lithe || !source.value.trim(); cancel.disabled = !active || cancelled; + $("preview-limit").disabled = busy; source.disabled = busy; requests.disabled = busy || !requests.options.length; $("pretty").disabled = tab !== "body" || !result || result.truncated || result.body == null; } @@ -32,14 +33,39 @@ function render() { if (result) { content = tab === "headers" ? `${result.version} ${result.status} ${result.statusText}\n${result.headers.map(([k,v]) => `${k}: ${v}`).join("\n")}` : result.body ?? t("binary"); if (tab === "body" && $("pretty").checked && !result.truncated && result.body != null) { - try { const formatted = JSON.stringify(JSON.parse(content), null, 2); if (formatted.length <= 128 * 1024) content = formatted; } catch { /* Non-JSON stays verbatim. */ } + try { const formatted = JSON.stringify(JSON.parse(content), null, 2); if (formatted.length <= 2 * 1024 * 1024) content = formatted; } catch { /* Non-JSON stays verbatim. */ } } } response.value = content; - $("notice").hidden = !result?.truncated; + response.hidden = tab === "headers"; + $("response-headers").hidden = tab !== "headers"; + document.querySelector(".pretty-label").hidden = tab !== "body"; + renderHeaders(); + $("notice").hidden = tab !== "body" || !result?.truncated; $("notice").textContent = result?.truncated ? t("truncated") : ""; controls(); } +let renderedHeaders = null; +function renderHeaders() { + if (renderedHeaders === result) return; + renderedHeaders = result; + const container = $("response-headers"); + container.replaceChildren(); + if (!result) return; + const protocol = document.createElement("p"); + protocol.className = "response-protocol"; + protocol.textContent = `${result.version} ${result.status} ${result.statusText}`; + container.append(protocol); + for (const [key, value] of result.headers) { + const detail = document.createElement("details"); + const summary = document.createElement("summary"); + summary.title = t("expandHeader"); + const name = document.createElement("span"); name.className = "header-key"; name.textContent = key; + const preview = document.createElement("span"); preview.className = "header-preview"; preview.textContent = value; + const full = document.createElement("div"); full.className = "header-value"; full.textContent = value; + summary.append(name, preview); detail.append(summary, full); container.append(detail); + } +} async function invoke(name, params) { active = crypto.randomUUID(); controls(); try { @@ -64,7 +90,7 @@ async function run(execute) { try { if (!execute || parsedText !== source.value) { if (!await parse()) return; } if (cancelled) return; - if (execute) result = await invoke("requests.execute", { text: source.value, index: Number(requests.value) }); + if (execute) result = await invoke("requests.execute", { text: source.value, index: Number(requests.value), previewLimitKiB: Number($("preview-limit").value) }); status("ready"); } catch (error) { const wasCancelled = cancelled || error?.code === "requestCancelled"; diff --git a/ui/style.css b/ui/style.css index fff97fd..55d9f4f 100644 --- a/ui/style.css +++ b/ui/style.css @@ -11,3 +11,18 @@ textarea:disabled { opacity: .75; } #response { white-space: pre-wrap; overflow- footer { min-height: 30px; border-top: 1px solid var(--line); } #status { overflow-wrap: anywhere; } .memory { white-space: nowrap; } @media (max-width: 620px) { main { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; } .request-pane { border-right: 0; border-bottom: 1px solid var(--line); } .shortcut, .memory { display: none; } .bar { height: 34px; } } @media (prefers-reduced-motion: reduce) { button { transition: none; } } + +[hidden] { display: none !important; } +.preview-setting { display: flex; gap: 8px; align-items: center; color: var(--muted); font-size: 11px; } +.preview-setting select { width: auto; } +#response-headers { flex: 1; min-height: 0; overflow: auto; padding: 0 12px 12px; font: 12px/1.7 var(--lithe-code-font-family, ui-monospace, monospace); } +.response-protocol { margin: 8px 0; color: var(--muted); } +#response-headers details { border-bottom: 1px solid var(--line); } +#response-headers summary { display: grid; grid-template-columns: 12px minmax(110px, 32%) minmax(0, 1fr); gap: 8px; padding: 7px 0; cursor: pointer; list-style: none; } +#response-headers summary::-webkit-details-marker { display: none; } +#response-headers summary::before { content: "▸"; color: var(--muted); } +#response-headers details[open] summary::before { content: "▾"; } +.header-key { color: var(--lithe-link, var(--accent)); font-weight: 600; overflow-wrap: anywhere; } +.header-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.header-value { white-space: pre-wrap; overflow-wrap: anywhere; padding: 0 0 10px 20px; user-select: text; } +#response-headers details[open] .header-preview { visibility: hidden; } From ddf5df1be2c70f1654a42a8274ab66eab7a37b1c Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 2 Nov 2026 01:28:21 -0800 Subject: [PATCH 2/2] fix: retain useful preview when JSON escaping reaches transport budget --- scripts/test_protocol.py | 1 + src/main.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/test_protocol.py b/scripts/test_protocol.py index 7da0493..e921b80 100644 --- a/scripts/test_protocol.py +++ b/scripts/test_protocol.py @@ -196,6 +196,7 @@ def test_configurable_preview_and_default(self): def test_escaped_preview_respects_transport_budget(self): value = self.execute(f"GET {self.url}/escaped")["result"] self.assertTrue(value["truncated"]) + self.assertGreater(len(value["body"]), 100 * 1024) self.assertLessEqual(len(json.dumps(value, separators=(",", ":")).encode()), 900 * 1024) def test_invalid_preview_limit_is_rejected(self): diff --git a/src/main.rs b/src/main.rs index c2fe6bf..0b449e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,7 +109,8 @@ fn main() -> Result<(), Box> { let Some(body) = response.body.as_mut().filter(|body| !body.is_empty()) else { return Err(invalid("httpClient.responseTooLarge")); }; - let mut end = body.len().saturating_sub(encoded_size - RESULT_LIMIT); + let reduction = (encoded_size - RESULT_LIMIT).min(body.len().div_ceil(2)); + let mut end = body.len() - reduction; while !body.is_char_boundary(end) { end -= 1; }