mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-15 08:08:18 +00:00
feat(http): convert requests to and from cURL, add environment files (#2592)
Convert to cURL and Copy copies the request at the caret as a cURL command with the selected environment applied; pasting a `curl ...` command into a .http file inserts the request it sends (headers, data variants, --json, -F multipart, -u, -b, -A, -e, -G, POSIX and Windows line continuations). Add Environment to Public / Private File opens the env file that applies to the request file, creating it next to it when missing. The conversion lives in ide-http and shares request preparation with running. Refs #2435
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
//! cURL ⇄ `.http` request: Convert to cURL and Copy, and the conversion of a pasted
|
||||
//! `curl …` command into a request.
|
||||
|
||||
use crate::PreparedRequest;
|
||||
|
||||
/// `req` as a cURL command line, one option per line (POSIX shell quoting).
|
||||
pub fn to_curl(req: &PreparedRequest) -> String {
|
||||
let mut parts = vec![format!("curl -X {} {}", req.method, quote(&req.url))];
|
||||
for (k, v) in &req.headers {
|
||||
parts.push(format!("-H {}", quote(&format!("{k}: {v}"))));
|
||||
}
|
||||
if let Some(body) = &req.body {
|
||||
parts.push(format!("--data-raw {}", quote(body)));
|
||||
}
|
||||
parts.join(" \\\n ")
|
||||
}
|
||||
|
||||
/// `s` in single quotes, as POSIX shells read it.
|
||||
fn quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
/// The request a `curl …` command sends, as `.http` text; `None` when `cmd` is not a cURL
|
||||
/// command or names no URL.
|
||||
pub fn from_curl(cmd: &str) -> Option<String> {
|
||||
let words = split_words(cmd)?;
|
||||
let mut args = words.iter().map(String::as_str);
|
||||
if args.next() != Some("curl") {
|
||||
return None;
|
||||
}
|
||||
let mut method: Option<String> = None;
|
||||
let mut url: Option<String> = None;
|
||||
let mut headers: Vec<(String, String)> = Vec::new();
|
||||
let mut data: Vec<String> = Vec::new();
|
||||
let mut form: Vec<(String, String)> = Vec::new();
|
||||
let mut get = false;
|
||||
let header = |headers: &mut Vec<(String, String)>, line: &str| {
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
headers.push((k.trim().to_owned(), v.trim().to_owned()));
|
||||
}
|
||||
};
|
||||
while let Some(arg) = args.next() {
|
||||
// `--opt=value` and `-Xvalue` forms.
|
||||
let (flag, inline) = match arg.split_once('=') {
|
||||
Some((f, v)) if arg.starts_with("--") => (f, Some(v.to_owned())),
|
||||
_ if arg.len() > 2 && arg.starts_with('-') && !arg.starts_with("--") && takes_value(&arg[..2]) => {
|
||||
(&arg[..2], Some(arg[2..].to_owned()))
|
||||
}
|
||||
_ => (arg, None),
|
||||
};
|
||||
let mut value = || inline.clone().or_else(|| args.next().map(str::to_owned));
|
||||
match flag {
|
||||
"-X" | "--request" => method = value(),
|
||||
"-H" | "--header" => header(&mut headers, &value()?),
|
||||
"-d" | "--data" | "--data-raw" | "--data-binary" | "--data-ascii" | "--data-urlencode" => {
|
||||
data.push(value()?)
|
||||
}
|
||||
"--json" => {
|
||||
data.push(value()?);
|
||||
headers.push(("Content-Type".into(), "application/json".into()));
|
||||
headers.push(("Accept".into(), "application/json".into()));
|
||||
}
|
||||
"-F" | "--form" | "--form-string" => {
|
||||
let v = value()?;
|
||||
let (k, v) = v.split_once('=')?;
|
||||
form.push((k.to_owned(), v.to_owned()));
|
||||
}
|
||||
"-u" | "--user" => {
|
||||
let v = value()?;
|
||||
let (user, pass) = v.split_once(':').unwrap_or((&v, ""));
|
||||
headers.push(("Authorization".into(), format!("Basic {user} {pass}").trim_end().to_owned()));
|
||||
}
|
||||
"-b" | "--cookie" => headers.push(("Cookie".into(), value()?)),
|
||||
"-A" | "--user-agent" => headers.push(("User-Agent".into(), value()?)),
|
||||
"-e" | "--referer" => headers.push(("Referer".into(), value()?)),
|
||||
"--url" => url = value(),
|
||||
"-G" | "--get" => get = true,
|
||||
f if f.starts_with('-') => {
|
||||
// Options with a value the request doesn't need (timeouts, output, proxy…).
|
||||
if takes_value(f) {
|
||||
value();
|
||||
}
|
||||
}
|
||||
u => {
|
||||
if url.is_none() {
|
||||
url = Some(u.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut url = url?;
|
||||
if get && !data.is_empty() {
|
||||
url.push(if url.contains('?') { '&' } else { '?' });
|
||||
url.push_str(&data.join("&"));
|
||||
data.clear();
|
||||
}
|
||||
let method = method.unwrap_or_else(|| if data.is_empty() && form.is_empty() { "GET" } else { "POST" }.into());
|
||||
let mut body = None;
|
||||
if !form.is_empty() {
|
||||
const BOUNDARY: &str = "WebAppBoundary";
|
||||
headers.retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
|
||||
headers.push(("Content-Type".into(), format!("multipart/form-data; boundary={BOUNDARY}")));
|
||||
let mut text = String::new();
|
||||
for (name, value) in &form {
|
||||
text.push_str(&format!("--{BOUNDARY}\n"));
|
||||
match value.strip_prefix('@') {
|
||||
Some(file) => {
|
||||
let file = file.split(';').next().unwrap_or(file);
|
||||
let base = file.rsplit(['/', '\\']).next().unwrap_or(file);
|
||||
text.push_str(&format!(
|
||||
"Content-Disposition: form-data; name=\"{name}\"; filename=\"{base}\"\n\n< {file}\n"
|
||||
));
|
||||
}
|
||||
None => text.push_str(&format!("Content-Disposition: form-data; name=\"{name}\"\n\n{value}\n")),
|
||||
}
|
||||
}
|
||||
text.push_str(&format!("--{BOUNDARY}--"));
|
||||
body = Some(text);
|
||||
} else if !data.is_empty() {
|
||||
if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type")) {
|
||||
headers.push(("Content-Type".into(), "application/x-www-form-urlencoded".into()));
|
||||
}
|
||||
body = Some(data.join("&"));
|
||||
}
|
||||
let mut out = format!("{method} {url}\n");
|
||||
for (k, v) in &headers {
|
||||
out.push_str(&format!("{k}: {v}\n"));
|
||||
}
|
||||
if let Some(body) = body {
|
||||
out.push('\n');
|
||||
out.push_str(&body);
|
||||
out.push('\n');
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Options that take a value, so it isn't read as the URL.
|
||||
fn takes_value(flag: &str) -> bool {
|
||||
matches!(
|
||||
flag,
|
||||
"-X" | "-H"
|
||||
| "-d"
|
||||
| "-F"
|
||||
| "-u"
|
||||
| "-b"
|
||||
| "-A"
|
||||
| "-e"
|
||||
| "-o"
|
||||
| "-x"
|
||||
| "-m"
|
||||
| "-c"
|
||||
| "-E"
|
||||
| "-T"
|
||||
| "-w"
|
||||
| "-r"
|
||||
| "-K"
|
||||
| "--output"
|
||||
| "--proxy"
|
||||
| "--max-time"
|
||||
| "--connect-timeout"
|
||||
| "--cookie-jar"
|
||||
| "--cert"
|
||||
| "--key"
|
||||
| "--cacert"
|
||||
| "--upload-file"
|
||||
| "--write-out"
|
||||
| "--range"
|
||||
| "--config"
|
||||
| "--retry"
|
||||
| "--resolve"
|
||||
| "--interface"
|
||||
)
|
||||
}
|
||||
|
||||
/// Shell words of `cmd`: single and double quotes, backslash escapes and line continuations
|
||||
/// (also `^` / backtick continuations from Windows shells). `None` for an unclosed quote.
|
||||
fn split_words(cmd: &str) -> Option<Vec<String>> {
|
||||
let mut words = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut in_word = false;
|
||||
let mut chars = cmd.trim().chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\'' => {
|
||||
in_word = true;
|
||||
loop {
|
||||
match chars.next()? {
|
||||
'\'' => break,
|
||||
c => cur.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' => {
|
||||
in_word = true;
|
||||
loop {
|
||||
match chars.next()? {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\' | '$' | '`')) => cur.push(chars.next()?),
|
||||
c => cur.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
'\\' | '^' | '`' if matches!(chars.peek(), Some('\n' | '\r')) => {
|
||||
while matches!(chars.peek(), Some('\n' | '\r')) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
'\\' => {
|
||||
in_word = true;
|
||||
if let Some(n) = chars.next() {
|
||||
cur.push(n);
|
||||
}
|
||||
}
|
||||
c if c.is_whitespace() => {
|
||||
if in_word {
|
||||
words.push(std::mem::take(&mut cur));
|
||||
in_word = false;
|
||||
}
|
||||
}
|
||||
c => {
|
||||
in_word = true;
|
||||
cur.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_word {
|
||||
words.push(cur);
|
||||
}
|
||||
Some(words)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_prepared_request_becomes_a_quoted_curl_command() {
|
||||
let req = PreparedRequest {
|
||||
name: None,
|
||||
method: "POST".into(),
|
||||
url: "https://api.example.com/users?q=a b".into(),
|
||||
headers: vec![("Content-Type".into(), "application/json".into())],
|
||||
body: Some("{\"name\": \"O'Brien\"}".into()),
|
||||
};
|
||||
assert_eq!(
|
||||
to_curl(&req),
|
||||
"curl -X POST 'https://api.example.com/users?q=a b' \\\n -H 'Content-Type: application/json' \\\n --data-raw '{\"name\": \"O'\\''Brien\"}'"
|
||||
);
|
||||
// And back.
|
||||
let http = from_curl(&to_curl(&req)).unwrap();
|
||||
assert_eq!(
|
||||
http,
|
||||
"POST https://api.example.com/users?q=a b\nContent-Type: application/json\n\n{\"name\": \"O'Brien\"}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_browser_copied_command_becomes_a_request() {
|
||||
let cmd = r#"curl 'https://example.com/api/items' \
|
||||
-H 'accept: application/json' \
|
||||
-H "authorization: Bearer abc" \
|
||||
--data-raw '{"a":1}' \
|
||||
--compressed -k -s"#;
|
||||
assert_eq!(
|
||||
from_curl(cmd).unwrap(),
|
||||
"POST https://example.com/api/items\naccept: application/json\nauthorization: Bearer abc\nContent-Type: application/x-www-form-urlencoded\n\n{\"a\":1}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_map_to_headers_query_and_multipart() {
|
||||
assert_eq!(
|
||||
from_curl("curl -G https://x.dev/s -d q=1 -d page=2 -u bob:secret -A agent --max-time 5").unwrap(),
|
||||
"GET https://x.dev/s?q=1&page=2\nAuthorization: Basic bob secret\nUser-Agent: agent\n"
|
||||
);
|
||||
assert_eq!(
|
||||
from_curl("curl -XPUT --url=https://x.dev/a --json '{}'").unwrap(),
|
||||
"PUT https://x.dev/a\nContent-Type: application/json\nAccept: application/json\n\n{}\n"
|
||||
);
|
||||
assert_eq!(
|
||||
from_curl("curl https://x.dev/up -F name=doc -F file=@/tmp/a.txt").unwrap(),
|
||||
"POST https://x.dev/up\nContent-Type: multipart/form-data; boundary=WebAppBoundary\n\n--WebAppBoundary\nContent-Disposition: form-data; name=\"name\"\n\ndoc\n--WebAppBoundary\nContent-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\n\n< /tmp/a.txt\n--WebAppBoundary--\n"
|
||||
);
|
||||
// Windows `cmd` continuation.
|
||||
assert_eq!(from_curl("curl ^\n \"https://x.dev\"").unwrap(), "GET https://x.dev\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_text_is_not_a_curl_command() {
|
||||
assert_eq!(from_curl("GET https://x.dev"), None);
|
||||
assert_eq!(from_curl("curl -H 'a: b'"), None, "no URL");
|
||||
assert_eq!(from_curl("curl 'https://x.dev"), None, "unclosed quote");
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,20 @@ fn read_first(dirs: &[PathBuf], name: &str) -> IdeResult<EnvMap> {
|
||||
Ok(EnvMap::new())
|
||||
}
|
||||
|
||||
/// The public (or private) environment file that applies to `file`: the nearest existing one,
|
||||
/// else a new one next to `file` holding a sample environment to fill in.
|
||||
pub fn environment_file(file: &Path, root: Option<&Path>, private: bool) -> IdeResult<PathBuf> {
|
||||
let name = if private { PRIVATE_FILE } else { PUBLIC_FILE };
|
||||
if let Some(found) = search_dirs(Some(file), root).into_iter().map(|d| d.join(name)).find(|p| p.is_file()) {
|
||||
return Ok(found);
|
||||
}
|
||||
let dir = file.parent().ok_or_else(|| IdeError::invalid("the request file has no folder"))?;
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, "{\n \"dev\": {\n \"name\": \"value\"\n }\n}\n")
|
||||
.map_err(|e| IdeError::internal(format!("cannot create {}: {e}", path.display())))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
impl Environments {
|
||||
pub fn load(file: Option<&Path>, root: Option<&Path>) -> IdeResult<Self> {
|
||||
let dirs = search_dirs(file, root);
|
||||
@@ -272,4 +286,18 @@ mod tests {
|
||||
let envs = Environments::load(Some(&tmp.path().join("a.http")), Some(tmp.path())).unwrap();
|
||||
assert_eq!(envs.names(), vec!["dev"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_environment_file_is_found_up_the_tree_or_created_next_to_the_request() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = tmp.path().join("api");
|
||||
std::fs::create_dir_all(&api).unwrap();
|
||||
let request = api.join("a.http");
|
||||
std::fs::write(tmp.path().join(PUBLIC_FILE), "{}").unwrap();
|
||||
assert_eq!(environment_file(&request, Some(tmp.path()), false).unwrap(), tmp.path().join(PUBLIC_FILE));
|
||||
let private = environment_file(&request, Some(tmp.path()), true).unwrap();
|
||||
assert_eq!(private, api.join(PRIVATE_FILE));
|
||||
let envs = Environments::load(Some(&request), Some(tmp.path())).unwrap();
|
||||
assert_eq!(envs.names(), vec!["dev"], "the new file is a valid environment file");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! of the backend, with the proxy of System Settings › HTTP Proxy.
|
||||
|
||||
pub mod client;
|
||||
pub mod curl;
|
||||
pub mod env;
|
||||
pub mod exec;
|
||||
pub mod history;
|
||||
@@ -32,13 +33,7 @@ pub struct RunInput<'a> {
|
||||
/// Prepares and sends request `index` of `text`. Preparation errors (bad
|
||||
/// variables, missing includes) fail; transport errors are part of the exchange.
|
||||
pub async fn run(input: RunInput<'_>, cancel: &CancellationToken) -> IdeResult<HttpExchange> {
|
||||
let parsed = parse::parse(input.text);
|
||||
let raw = parsed
|
||||
.requests
|
||||
.get(input.index as usize)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no request #{}", input.index + 1)))?;
|
||||
let envs = Environments::load(input.file, input.root)?;
|
||||
let request = exec::prepare(raw, &parsed.variables, &envs, input.env, input.file.and_then(Path::parent))?;
|
||||
let request = prepare(&input)?;
|
||||
let time_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs_f64() * 1000.0)
|
||||
@@ -70,6 +65,22 @@ pub async fn run(input: RunInput<'_>, cancel: &CancellationToken) -> IdeResult<H
|
||||
})
|
||||
}
|
||||
|
||||
/// Request `index` of `text` with its variables substituted and includes read.
|
||||
pub fn prepare(input: &RunInput<'_>) -> IdeResult<PreparedRequest> {
|
||||
let parsed = parse::parse(input.text);
|
||||
let raw = parsed
|
||||
.requests
|
||||
.get(input.index as usize)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no request #{}", input.index + 1)))?;
|
||||
let envs = Environments::load(input.file, input.root)?;
|
||||
exec::prepare(raw, &parsed.variables, &envs, input.env, input.file.and_then(Path::parent))
|
||||
}
|
||||
|
||||
/// Convert to cURL and Copy: request `index` of `text`, resolved, as a cURL command.
|
||||
pub fn curl(input: &RunInput<'_>) -> IdeResult<String> {
|
||||
prepare(input).map(|r| curl::to_curl(&r))
|
||||
}
|
||||
|
||||
/// Environment names available to `file`.
|
||||
pub fn environments(file: Option<&Path>, root: Option<&Path>) -> IdeResult<Vec<String>> {
|
||||
Ok(Environments::load(file, root)?.names())
|
||||
|
||||
@@ -84,3 +84,54 @@ pub fn http_history_clear(window: Window, state: State<'_, AppState>) -> IdeResu
|
||||
pub async fn proxy_check_connection(proxy: ide_core::settings::ProxySettings, url: String) -> IdeResult<String> {
|
||||
ide_http::client::check_connection(&proxy, &url, &ide_core::CancellationToken::new()).await
|
||||
}
|
||||
|
||||
/// Convert to cURL and Copy: request `index` of `text` (the editor buffer of `path`), with
|
||||
/// the variables of `env` substituted, as a cURL command.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn http_curl(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: Option<IpcPath>,
|
||||
text: String,
|
||||
index: u32,
|
||||
env: Option<String>,
|
||||
) -> IdeResult<String> {
|
||||
let root = state.project(window.label()).ok().map(|p| p.root.clone());
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
ide_http::curl(&ide_http::RunInput {
|
||||
text: &text,
|
||||
index,
|
||||
file: path.as_deref(),
|
||||
root: root.as_deref(),
|
||||
env: env.as_deref(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// A pasted `curl …` command as `.http` request text; `None` when it isn't one.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn http_from_curl(command: String) -> Option<String> {
|
||||
ide_http::curl::from_curl(&command)
|
||||
}
|
||||
|
||||
/// The environment file for the request file `path` (`is_private`: the private one): the nearest
|
||||
/// existing one, else a new one next to it.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn http_environment_file(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: IpcPath,
|
||||
is_private: bool,
|
||||
) -> IdeResult<IpcPath> {
|
||||
let root = state.project(window.label()).ok().map(|p| p.root.clone());
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
ide_http::env::environment_file(&path, root.as_deref(), is_private).map(IpcPath::from)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
@@ -603,6 +603,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
http::http_requests,
|
||||
http::http_environments,
|
||||
http::http_execute,
|
||||
http::http_curl,
|
||||
http::http_from_curl,
|
||||
http::http_environment_file,
|
||||
http::http_history,
|
||||
http::http_history_get,
|
||||
http::http_history_clear,
|
||||
|
||||
@@ -1369,6 +1369,18 @@ export const commands = {
|
||||
* the exchange in the response history. Cancel with `cancel_request(request)`.
|
||||
*/
|
||||
httpExecute: (request: RequestId, path: string | null, text: string, index: number, env: string | null) => typedError<HttpExchange, IdeError>(__TAURI_INVOKE("http_execute", { request, path, text, index, env })),
|
||||
/**
|
||||
* Convert to cURL and Copy: request `index` of `text` (the editor buffer of `path`), with
|
||||
* the variables of `env` substituted, as a cURL command.
|
||||
*/
|
||||
httpCurl: (path: string | null, text: string, index: number, env: string | null) => typedError<string, IdeError>(__TAURI_INVOKE("http_curl", { path, text, index, env })),
|
||||
/** A pasted `curl …` command as `.http` request text; `None` when it isn't one. */
|
||||
httpFromCurl: (command: string) => __TAURI_INVOKE<string | null>("http_from_curl", { command }),
|
||||
/**
|
||||
* The environment file for the request file `path` (`is_private`: the private one): the nearest
|
||||
* existing one, else a new one next to it.
|
||||
*/
|
||||
httpEnvironmentFile: (path: IpcPath, isPrivate: boolean) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("http_environment_file", { path, isPrivate })),
|
||||
httpHistory: () => typedError<HttpHistoryEntry[], IdeError>(__TAURI_INVOKE("http_history")),
|
||||
httpHistoryGet: (id: string) => typedError<HttpExchange, IdeError>(__TAURI_INVOKE("http_history_get", { id })),
|
||||
httpHistoryClear: () => typedError<null, IdeError>(__TAURI_INVOKE("http_history_clear")),
|
||||
|
||||
@@ -11,11 +11,13 @@ import type { EditorDocContext } from "@/editor/api";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import type { HttpRequestBlock } from "@/platform/ipc";
|
||||
import { IconButton, Select, gutterIconMarkup, T } from "@/ui";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { IconButton, Select, gutterIconMarkup, showContextMenu, T } from "@/ui";
|
||||
import {
|
||||
cancelRunning,
|
||||
loadEnvironments,
|
||||
parseRequests,
|
||||
requestFromCurl,
|
||||
runAll,
|
||||
runRequest,
|
||||
setEnvironment,
|
||||
@@ -129,6 +131,27 @@ function Toolbar({ path, view }: { path: string | null; view: EditorView }) {
|
||||
disabled={!path}
|
||||
onClick={() => path && void loadEnvironments(path)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add Environment File"
|
||||
disabled={!path}
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu({ x: r.left, y: r.bottom }, [
|
||||
{ kind: "action", action: "http.addPublicEnvironment" },
|
||||
{ kind: "action", action: "http.addPrivateEnvironment" },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="copy"
|
||||
title="Convert to cURL and Copy"
|
||||
shortcut={shortcutLabel("http.copyAsCurl")}
|
||||
onClick={() => {
|
||||
view.focus();
|
||||
void executeAction("http.copyAsCurl");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -144,6 +167,25 @@ function toolbarPanel(path: string | null) {
|
||||
|
||||
const DEBOUNCE = 300;
|
||||
|
||||
const CURL = /^\s*curl\s/;
|
||||
|
||||
/** A pasted `curl …` command becomes the request it sends. */
|
||||
const curlPaste = EditorView.domEventHandlers({
|
||||
paste(event, view) {
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
if (!text || !CURL.test(text) || view.state.readOnly) return false;
|
||||
event.preventDefault();
|
||||
const { doc } = view.state;
|
||||
void requestFromCurl(text)
|
||||
.catch(() => null)
|
||||
.then((request) => {
|
||||
if (view.state.doc !== doc) return;
|
||||
view.dispatch(view.state.replaceSelection(request ?? text), { scrollIntoView: true, userEvent: "input.paste" });
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export function httpEditorExtension(ctx: EditorDocContext): Extension {
|
||||
if (ctx.language !== HTTP_LANGUAGE) return [];
|
||||
const path = ctx.virtual || isVirtualUri(ctx.path) ? null : ctx.path;
|
||||
@@ -170,6 +212,7 @@ export function httpEditorExtension(ctx: EditorDocContext): Extension {
|
||||
return [
|
||||
requestsField,
|
||||
detector,
|
||||
curlPaste,
|
||||
showPanel.of(toolbarPanel(path)),
|
||||
gutter({
|
||||
class: `${styles.runGutter} cm-openidea-gutter-http`,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } from "./editor";
|
||||
import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView";
|
||||
import { httpLanguageDescription } from "./language";
|
||||
import { SERVICE_VIEW, resetHttpClient, runAll, runRequest } from "./store";
|
||||
import { SERVICE_VIEW, copyAsCurl, openEnvironmentFile, resetHttpClient, runAll, runRequest } from "./store";
|
||||
import {
|
||||
GENERATE_CONTROLLER_REQUESTS,
|
||||
GENERATE_REQUEST,
|
||||
@@ -65,6 +65,24 @@ async function runAtCaret() {
|
||||
}
|
||||
|
||||
/** New › HTTP Request in `directory`: the name prompt of the file templates (features/templates), with the example body. */
|
||||
async function copyAtCaret() {
|
||||
const e = activeHttpEditor();
|
||||
if (!e) return;
|
||||
await refreshRequests(e.view);
|
||||
const r = requestAtCaret(e.view);
|
||||
if (!r) {
|
||||
notifications().notify({ level: "info", title: "No HTTP request at the caret", key: "http-run" });
|
||||
return;
|
||||
}
|
||||
await copyAsCurl(filePath(e.ctx.path, e.ctx.virtual), e.view.state.doc.toString(), r.index);
|
||||
}
|
||||
|
||||
function addEnvironment(isPrivate: boolean) {
|
||||
const e = activeHttpEditor();
|
||||
const path = e && filePath(e.ctx.path, e.ctx.virtual);
|
||||
if (path) void openEnvironmentFile(path, isPrivate);
|
||||
}
|
||||
|
||||
function newRequestArg(directory: string) {
|
||||
return {
|
||||
directory,
|
||||
@@ -158,6 +176,33 @@ export function activate(): () => void {
|
||||
when: "editorTextFocus && editorLangId == http",
|
||||
run: () => runAtCaret(),
|
||||
},
|
||||
{
|
||||
id: "http.copyAsCurl",
|
||||
title: "Convert to cURL and Copy",
|
||||
category: "Tools",
|
||||
icon: "copy",
|
||||
keywords: ["curl", "http client", "copy"],
|
||||
when: "editorLangId == http",
|
||||
run: () => copyAtCaret(),
|
||||
},
|
||||
{
|
||||
id: "http.addPublicEnvironment",
|
||||
title: "Add Environment to Public File",
|
||||
description: "Opens http-client.env.json for this request file, creating it if missing",
|
||||
category: "Tools",
|
||||
keywords: ["http client", "environment", "env.json"],
|
||||
when: "editorLangId == http",
|
||||
run: () => addEnvironment(false),
|
||||
},
|
||||
{
|
||||
id: "http.addPrivateEnvironment",
|
||||
title: "Add Environment to Private File",
|
||||
description: "Opens http-client.private.env.json for this request file, creating it if missing",
|
||||
category: "Tools",
|
||||
keywords: ["http client", "environment", "env.json", "secrets"],
|
||||
when: "editorLangId == http",
|
||||
run: () => addEnvironment(true),
|
||||
},
|
||||
{
|
||||
id: "http.runAll",
|
||||
title: "Run All HTTP Requests in File",
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
type HttpRequestBlock,
|
||||
type RequestId,
|
||||
} from "@/platform/ipc";
|
||||
import { notifications, showServiceView } from "@/workbench/contributions";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { editorService, notifications, showServiceView, showStatusInfo } from "@/workbench/contributions";
|
||||
|
||||
export const SERVICE_VIEW = "httpClient";
|
||||
const ENV_KEY = "openidea.httpClient.environments";
|
||||
@@ -158,6 +159,43 @@ export async function runRequest(path: string | null, text: string, index: numbe
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert to cURL and Copy: request `index` of `text`, with the file's environment applied. */
|
||||
export async function copyAsCurl(path: string | null, text: string, index: number): Promise<void> {
|
||||
try {
|
||||
if (path && !useHttpClient.getState().environments[path]) await loadEnvironments(path);
|
||||
await writeClipboardText(await call(commands.httpCurl(path, text, index, effectiveEnvironment(path))));
|
||||
showStatusInfo("The request was copied as a cURL command");
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot convert the request to cURL",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
key: "http-curl",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A pasted `curl …` command as request text; null when it isn't one. */
|
||||
export function requestFromCurl(command: string): Promise<string | null> {
|
||||
return commands.httpFromCurl(command);
|
||||
}
|
||||
|
||||
/** Add Environment to Public / Private File: opens the env file that applies to `path`, created if missing. */
|
||||
export async function openEnvironmentFile(path: string, isPrivate: boolean): Promise<void> {
|
||||
try {
|
||||
const file = await call(commands.httpEnvironmentFile(path, isPrivate));
|
||||
await editorService().openFile(file, { focus: true });
|
||||
void loadEnvironments(path);
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot create the environment file",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
key: "http-env-file",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs every request of the file in order; stops when one is cancelled. */
|
||||
export async function runAll(path: string | null, text: string): Promise<void> {
|
||||
const blocks = await parseRequests(text);
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"Add Environment File": "Add Environment File",
|
||||
"Convert to cURL and Copy": "Convert to cURL and Copy",
|
||||
"Add Environment to Public File": "Add Environment to Public File",
|
||||
"Add Environment to Private File": "Add Environment to Private File",
|
||||
"Opens http-client.env.json for this request file, creating it if missing": "Opens http-client.env.json for this request file, creating it if missing",
|
||||
"Opens http-client.private.env.json for this request file, creating it if missing": "Opens http-client.private.env.json for this request file, creating it if missing",
|
||||
"Cannot convert the request to cURL": "Cannot convert the request to cURL",
|
||||
"Cannot create the environment file": "Cannot create the environment file",
|
||||
"The request was copied as a cURL command": "The request was copied as a cURL command",
|
||||
"Generate Sources and Update Folders For All Projects": "Generate Sources and Update Folders For All Projects",
|
||||
"Generate Sources and Update Folders": "Generate Sources and Update Folders",
|
||||
"Runs generate-sources, then reloads the Maven projects so generated folders become source folders": "Runs generate-sources, then reloads the Maven projects so generated folders become source folders",
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"Add Environment File": "添加环境文件",
|
||||
"Convert to cURL and Copy": "转换为 cURL 并复制",
|
||||
"Add Environment to Public File": "将环境添加到公共文件",
|
||||
"Add Environment to Private File": "将环境添加到私有文件",
|
||||
"Opens http-client.env.json for this request file, creating it if missing": "打开此请求文件对应的 http-client.env.json,不存在时创建",
|
||||
"Opens http-client.private.env.json for this request file, creating it if missing": "打开此请求文件对应的 http-client.private.env.json,不存在时创建",
|
||||
"Cannot convert the request to cURL": "无法将请求转换为 cURL",
|
||||
"Cannot create the environment file": "无法创建环境文件",
|
||||
"The request was copied as a cURL command": "已将请求复制为 cURL 命令",
|
||||
"Related targets": "相关目标",
|
||||
"GDB (C, C++)": "GDB(C、C++)",
|
||||
"Unknown error": "未知错误",
|
||||
|
||||
Reference in New Issue
Block a user