diff --git a/crates/ide-http/src/curl.rs b/crates/ide-http/src/curl.rs new file mode 100644 index 000000000..56fb34fed --- /dev/null +++ b/crates/ide-http/src/curl.rs @@ -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 { + 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 = None; + let mut url: Option = None; + let mut headers: Vec<(String, String)> = Vec::new(); + let mut data: Vec = 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> { + 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"); + } +} diff --git a/crates/ide-http/src/env.rs b/crates/ide-http/src/env.rs index 2a693a58f..f95c15e21 100644 --- a/crates/ide-http/src/env.rs +++ b/crates/ide-http/src/env.rs @@ -86,6 +86,20 @@ fn read_first(dirs: &[PathBuf], name: &str) -> IdeResult { 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 { + 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 { 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"); + } } diff --git a/crates/ide-http/src/lib.rs b/crates/ide-http/src/lib.rs index 27b5f3f08..91e88473f 100644 --- a/crates/ide-http/src/lib.rs +++ b/crates/ide-http/src/lib.rs @@ -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 { - 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) -> IdeResult { + 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 { + prepare(input).map(|r| curl::to_curl(&r)) +} + /// Environment names available to `file`. pub fn environments(file: Option<&Path>, root: Option<&Path>) -> IdeResult> { Ok(Environments::load(file, root)?.names()) diff --git a/src-tauri/src/commands/http.rs b/src-tauri/src/commands/http.rs index 9424ecf04..f81502b4a 100644 --- a/src-tauri/src/commands/http.rs +++ b/src-tauri/src/commands/http.rs @@ -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 { 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, + text: String, + index: u32, + env: Option, +) -> IdeResult { + 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 { + 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 { + 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()))? +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d72bc29d3..907bdc07a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -603,6 +603,9 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src/bindings.ts b/src/bindings.ts index d9d92fe27..8f0b06ef1 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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(__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(__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("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(__TAURI_INVOKE("http_environment_file", { path, isPrivate })), httpHistory: () => typedError(__TAURI_INVOKE("http_history")), httpHistoryGet: (id: string) => typedError(__TAURI_INVOKE("http_history_get", { id })), httpHistoryClear: () => typedError(__TAURI_INVOKE("http_history_clear")), diff --git a/src/features/http-client/editor.tsx b/src/features/http-client/editor.tsx index 7108924c9..32e6a5637 100644 --- a/src/features/http-client/editor.tsx +++ b/src/features/http-client/editor.tsx @@ -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)} /> + { + const r = e.currentTarget.getBoundingClientRect(); + showContextMenu({ x: r.left, y: r.bottom }, [ + { kind: "action", action: "http.addPublicEnvironment" }, + { kind: "action", action: "http.addPrivateEnvironment" }, + ]); + }} + /> + { + view.focus(); + void executeAction("http.copyAsCurl"); + }} + /> ); } @@ -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`, diff --git a/src/features/http-client/index.ts b/src/features/http-client/index.ts index 90f6a4581..df099d166 100644 --- a/src/features/http-client/index.ts +++ b/src/features/http-client/index.ts @@ -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", diff --git a/src/features/http-client/store.ts b/src/features/http-client/store.ts index 0ff936b95..83c5ab9ea 100644 --- a/src/features/http-client/store.ts +++ b/src/features/http-client/store.ts @@ -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 { + 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 { + 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 { + 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 { const blocks = await parseRequests(text); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index e2a8fcb8b..e76efc5b0 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index eeafdcaec..5bd9cb224 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "未知错误",