Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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.
Expand All @@ -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`.

Expand Down
1 change: 1 addition & 0 deletions assets/http-tool.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 42 additions & 7 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
}
}
1 change: 1 addition & 0 deletions scripts/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
25 changes: 23 additions & 2 deletions scripts/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -177,12 +178,32 @@ 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.assertGreater(len(value["body"]), 100 * 1024)
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()), [])
Expand Down
11 changes: 10 additions & 1 deletion scripts/test_ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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='<img src=x onerror="window.injected=true">';
Expand Down
37 changes: 30 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -29,6 +30,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
})?;
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(&params)?;
let parsed = parse::parse(text);
let index = params["index"]
Expand Down Expand Up @@ -80,20 +89,34 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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 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;
}
body.truncate(end);
response.truncated = true;
}
Ok(value)
})?;
std::process::exit(backend.listen());
}
4 changes: 2 additions & 2 deletions ui/index.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HTTP Client</title><link rel="stylesheet" href="style.css"><script src="main.js" defer></script></head>
<body>
<header><strong>HTTP Client</strong><span class="spacer"></span><span class="shortcut">⌘ / Ctrl ↵</span><button id="cancel" data-i18n="cancel" disabled></button><button id="send" class="primary" data-i18n="send" disabled></button></header>
<header><label class="preview-setting"><span data-i18n="previewLimit"></span><select id="preview-limit" data-label="previewLimit"><option value="48">48 KiB</option><option value="256">256 KiB</option><option value="512" selected>512 KiB</option></select></label><span class="spacer"></span><span class="shortcut">⌘ / Ctrl ↵</span><button id="cancel" data-i18n="cancel" disabled></button><button id="send" class="primary" data-i18n="send" disabled></button></header>
<main>
<section class="request-pane"><div class="bar"><label for="source" data-i18n="source"></label><span class="spacer"></span><button id="parse" data-i18n="parse"></button></div>
<div class="selection"><select id="requests" data-label="select"></select></div>
<textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" data-label="source"></textarea></section>
<section class="response-pane"><div class="bar"><span data-i18n="response"></span><span id="metrics" class="spacer"></span><span id="code"></span></div>
<div class="response-tools"><button id="body-tab" aria-pressed="true" data-i18n="body"></button><button id="headers-tab" aria-pressed="false" data-i18n="headers"></button><span class="spacer"></span><label class="pretty-label"><input type="checkbox" id="pretty" checked><span data-i18n="pretty"></span></label></div>
<textarea id="response" readonly spellcheck="false" data-label="response"></textarea><p id="notice" hidden></p></section>
<div id="response-headers" hidden></div><textarea id="response" readonly spellcheck="false" data-label="response"></textarea><p id="notice" hidden></p></section>
</main><footer><span id="status" role="status" aria-live="polite"></span><span class="spacer"></span><span data-i18n="memory" class="memory"></span></footer>
</body></html>
Loading
Loading