From bbf85c953db76ea48dbf257fcc98946eaa2cbf86 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 24 Oct 2025 09:50:42 -0700 Subject: [PATCH] Better XML formatting, fix pointer cursor in sidebar, copy/create URL in response --- src-tauri/src/lib.rs | 7 + src-tauri/yaak-templates/src/format_xml.rs | 345 ++++++++++++++++++ src-tauri/yaak-templates/src/lib.rs | 1 + src-web/components/GlobalHooks.tsx | 1 - src-web/components/core/Editor/Editor.css | 14 +- .../core/Editor/hyperlink/extension.ts | 41 ++- src-web/components/core/tree/TreeItem.tsx | 2 +- src-web/lib/formatters.ts | 18 +- src-web/lib/tauri.ts | 1 + 9 files changed, 404 insertions(+), 26 deletions(-) create mode 100644 src-tauri/yaak-templates/src/format_xml.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8d1e67d2..e6cecdf8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,6 +45,7 @@ use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_sse::sse::ServerSentEvent; use yaak_templates::format::format_json; use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args}; +use yaak_templates::format_xml::format_xml; mod commands; mod encoding; @@ -739,6 +740,11 @@ async fn cmd_format_json(text: &str) -> YaakResult { Ok(format_json(text, " ")) } +#[tauri::command] +async fn cmd_format_xml(text: &str) -> YaakResult { + Ok(format_xml(text, " ")) +} + #[tauri::command] async fn cmd_http_response_body( window: WebviewWindow, @@ -1415,6 +1421,7 @@ pub fn run() { cmd_export_data, cmd_http_response_body, cmd_format_json, + cmd_format_xml, cmd_get_http_authentication_summaries, cmd_get_http_authentication_config, cmd_get_sse_events, diff --git a/src-tauri/yaak-templates/src/format_xml.rs b/src-tauri/yaak-templates/src/format_xml.rs new file mode 100644 index 00000000..8ac23a3b --- /dev/null +++ b/src-tauri/yaak-templates/src/format_xml.rs @@ -0,0 +1,345 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum XmlTok<'a> { + OpenTag { raw: &'a str, name: &'a str }, // "" + CloseTag { raw: &'a str, name: &'a str }, // "" + SelfCloseTag(&'a str), // "" + Comment(&'a str), // "" + CData(&'a str), // "" + ProcInst(&'a str), // "" + Doctype(&'a str), // "" + Text(&'a str), // "text between tags" + Template(&'a str), // "${[ ... ]}" +} + +fn writeln_indented(out: &mut String, depth: usize, indent: &str, s: &str) { + for _ in 0..depth { + out.push_str(indent); + } + out.push_str(s); + out.push('\n'); +} + +pub fn format_xml(input: &str, indent: &str) -> String { + use XmlTok::*; + let tokens = tokenize_with_templates(input); + + let mut out = String::new(); + let mut depth = 0usize; + let mut i = 0usize; + + while i < tokens.len() { + match tokens[i] { + OpenTag { + raw: open_raw, + name: open_name, + } => { + if i + 2 < tokens.len() { + if let Text(text_raw) = tokens[i + 1] { + let trimmed = text_raw.trim(); + let no_newlines = !trimmed.contains('\n'); + if no_newlines && !trimmed.is_empty() { + if let CloseTag { + raw: close_raw, + name: close_name, + } = tokens[i + 2] + { + if open_name == close_name { + for _ in 0..depth { + out.push_str(indent); + } + out.push_str(open_raw); + out.push_str(trimmed); + out.push_str(close_raw); + out.push('\n'); + i += 3; + continue; + } + } + } + } + } + writeln_indented(&mut out, depth, indent, open_raw); + depth = depth.saturating_add(1); + i += 1; + } + + CloseTag { raw, .. } => { + depth = depth.saturating_sub(1); + writeln_indented(&mut out, depth, indent, raw); + i += 1; + } + + SelfCloseTag(raw) | Comment(raw) | ProcInst(raw) | Doctype(raw) | CData(raw) + | Template(raw) => { + writeln_indented(&mut out, depth, indent, raw); + i += 1; + } + + Text(text_raw) => { + if text_raw.chars().any(|c| !c.is_whitespace()) { + let trimmed = text_raw.trim(); + writeln_indented(&mut out, depth, indent, trimmed); + } + i += 1; + } + } + } + + if out.ends_with('\n') { + out.pop(); + } + out +} + +fn tokenize_with_templates(input: &str) -> Vec> { + use XmlTok::*; + let bytes = input.as_bytes(); + let mut i = 0usize; + let mut toks = Vec::::new(); + + let starts_with = + |s: &[u8], i: usize, pat: &str| s.get(i..).map_or(false, |t| t.starts_with(pat.as_bytes())); + + while i < bytes.len() { + // Template block: ${[ ... ]} + if starts_with(bytes, i, "${[") { + let start = i; + i += 3; + while i < bytes.len() && !starts_with(bytes, i, "]}") { + i += 1; + } + if starts_with(bytes, i, "]}") { + i += 2; + } + toks.push(Template(&input[start..i])); + continue; + } + + if bytes[i] == b'<' { + // Comments + if starts_with(bytes, i, "") { + i += 1; + } + if starts_with(bytes, i, "-->") { + i += 3; + } + toks.push(Comment(&input[start..i])); + continue; + } + // CDATA + if starts_with(bytes, i, "") { + i += 1; + } + if starts_with(bytes, i, "]]>") { + i += 3; + } + toks.push(CData(&input[start..i])); + continue; + } + // Processing Instruction + if starts_with(bytes, i, "") { + i += 1; + } + if starts_with(bytes, i, "?>") { + i += 2; + } + toks.push(ProcInst(&input[start..i])); + continue; + } + // DOCTYPE or other "' { + i += 1; + } + if i < bytes.len() { + i += 1; + } + toks.push(Doctype(&input[start..i])); + continue; + } + + // Normal tag (open/close/self) + let start = i; + i += 1; // '<' + + let is_close = if i < bytes.len() && bytes[i] == b'/' { + i += 1; + true + } else { + false + }; + + // read until '>' (respecting quotes) + let mut in_quote: Option = None; + while i < bytes.len() { + let c = bytes[i]; + if let Some(q) = in_quote { + if c == q { + in_quote = None; + } + i += 1; + } else { + if c == b'\'' || c == b'"' { + in_quote = Some(c); + i += 1; + } else if c == b'>' { + i += 1; + break; + } else { + i += 1; + } + } + } + + let raw = &input[start..i]; + let is_self = raw.as_bytes().len() >= 2 && raw.as_bytes()[raw.len() - 2] == b'/'; + if is_close { + let name = parse_close_name(raw); + toks.push(CloseTag { raw, name }); + } else if is_self { + toks.push(SelfCloseTag(raw)); + } else { + let name = parse_open_name(raw); + toks.push(OpenTag { raw, name }); + } + continue; + } + + // Text node until next '<' or template start + let start = i; + while i < bytes.len() && bytes[i] != b'<' && !starts_with(bytes, i, "${[") { + i += 1; + } + toks.push(XmlTok::Text(&input[start..i])); + } + + toks +} + +fn parse_open_name(raw: &str) -> &str { + // raw looks like "" or "" + // slice between '<' and first whitespace or '>' or '/>' + let s = &raw[1..]; // skip '<' + let end = s.find(|c: char| c.is_whitespace() || c == '>' || c == '/').unwrap_or(s.len()); + &s[..end] +} + +fn parse_close_name(raw: &str) -> &str { + // raw looks like "" + let s = &raw[2..]; // skip "').unwrap_or(s.len()); + &s[..end] +} + +#[cfg(test)] +mod tests { + use super::format_xml; + + #[test] + fn inline_text_child() { + let src = r#"this might be a stringok"#; + let want = r#" + this might be a string + ok +"#; + assert_eq!(format_xml(src, " "), want); + } + + #[test] + fn works_when_nested() { + let src = r#"bold"#; + let want = r#" + + bold + +"#; + assert_eq!(format_xml(src, " "), want); + } + + #[test] + fn trims_and_keeps_nonempty() { + let src = " hi "; + let want = "\n hi\n"; + assert_eq!(format_xml(src, " "), want); + } + #[test] + fn attributes_inline_text_child() { + // Keeps attributes verbatim and inlines simple text children + let src = r#"value"#; + let want = r#" + value +"#; + assert_eq!(format_xml(src, " "), want); + } + + #[test] + fn attributes_with_irregular_spacing_preserved() { + // We don't normalize spaces inside the tag; raw is preserved + let src = r#"t"#; + let want = r#" + t +"#; + assert_eq!(format_xml(src, " "), want); + } + + #[test] + fn self_closing_with_attributes() { + let src = + r#"hello "world""#; + let want = r#" + hello "world" +"#; + assert_eq!(format_xml(src, " "), want); + } + + #[test] + fn template_in_attribute_self_closing() { + let src = r#""#; + let want = r#" + +"#; + assert_eq!(format_xml(src, " "), want); + } + + #[test] + fn attributes_and_nested_children_expand() { + // Not inlined because child is an element, not plain text + let src = r#"bold"#; + let want = r#" + + bold + +"#; + assert_eq!(format_xml(src, " "), want); + } + + #[test] + fn namespace_and_xml_attrs() { + let src = r#"ok"#; + let want = r#" + ok +"#; + assert_eq!(format_xml(src, " "), want); + } + + #[test] + fn mixed_quote_styles_in_attributes() { + // Single-quoted attr containing double quotes is fine; we don't re-quote + let src = r#"hello"#; + let want = r#" + hello +"#; + assert_eq!(format_xml(src, " "), want); + } +} diff --git a/src-tauri/yaak-templates/src/lib.rs b/src-tauri/yaak-templates/src/lib.rs index e4c699d3..adeb7bcd 100644 --- a/src-tauri/yaak-templates/src/lib.rs +++ b/src-tauri/yaak-templates/src/lib.rs @@ -4,6 +4,7 @@ pub mod format; pub mod parser; pub mod renderer; pub mod wasm; +pub mod format_xml; pub use parser::*; pub use renderer::*; diff --git a/src-web/components/GlobalHooks.tsx b/src-web/components/GlobalHooks.tsx index f98d2c7f..cd6253ab 100644 --- a/src-web/components/GlobalHooks.tsx +++ b/src-web/components/GlobalHooks.tsx @@ -1,4 +1,3 @@ -import { activeRequestAtom } from '../hooks/useActiveRequest'; import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace'; import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast'; import { useHotKey, useSubscribeHotKeys } from '../hooks/useHotKey'; diff --git a/src-web/components/core/Editor/Editor.css b/src-web/components/core/Editor/Editor.css index a663d8f9..726f13ee 100644 --- a/src-web/components/core/Editor/Editor.css +++ b/src-web/components/core/Editor/Editor.css @@ -238,15 +238,15 @@ .cm-tooltip.cm-tooltip-hover { @apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm; - @apply px-2 py-1; + @apply p-1.5; + + /* Style the tooltip for popping up "open in browser" and other stuff */ + a, button { + @apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded; + } a { - @apply text-text; - - &:hover { - @apply underline; - } - + @apply cursor-default !important; &::after { @apply text-text bg-text h-3 w-3 ml-1; content: ''; diff --git a/src-web/components/core/Editor/hyperlink/extension.ts b/src-web/components/core/Editor/hyperlink/extension.ts index 98fe5803..28effeb7 100644 --- a/src-web/components/core/Editor/hyperlink/extension.ts +++ b/src-web/components/core/Editor/hyperlink/extension.ts @@ -1,5 +1,9 @@ import type { DecorationSet, ViewUpdate } from '@codemirror/view'; import { Decoration, EditorView, hoverTooltip, MatchDecorator, ViewPlugin } from '@codemirror/view'; +import { activeWorkspaceIdAtom } from '../../../../hooks/useActiveWorkspace'; +import { copyToClipboard } from '../../../../lib/copy'; +import { createRequestAndNavigate } from '../../../../lib/createRequestAndNavigate'; +import { jotaiStore } from '../../../../lib/jotai'; const REGEX = /(https?:\/\/([-a-zA-Z0-9@:%._+*~#=]{1,256})+(\.[a-zA-Z0-9()]{1,6})?\b([-a-zA-Z0-9()@:%_+*.~#?&/={}[\]]*))/g; @@ -32,11 +36,38 @@ const tooltip = hoverTooltip( pos: found.start, end: found.end, create() { - const dom = document.createElement('a'); - dom.textContent = 'Open in browser'; - dom.href = text.substring(found!.start - from, found!.end - from); - dom.target = '_blank'; - dom.rel = 'noopener noreferrer'; + const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); + const link = text.substring(found!.start - from, found!.end - from); + const dom = document.createElement('div'); + + const $open = document.createElement('a'); + $open.textContent = 'Open in browser'; + $open.href = link; + $open.target = '_blank'; + $open.rel = 'noopener noreferrer'; + + const $copy = document.createElement('button'); + $copy.textContent = 'Copy to clipboard'; + $copy.addEventListener('click', () => { + copyToClipboard(link); + }); + + const $create = document.createElement('button'); + $create.textContent = 'Create new request'; + $create.addEventListener('click', async () => { + await createRequestAndNavigate({ + model: 'http_request', + workspaceId: workspaceId ?? 'n/a', + url: link, + }); + }); + + dom.appendChild($open); + dom.appendChild($copy); + if (workspaceId != null) { + dom.appendChild($create); + } + return { dom }; }, }; diff --git a/src-web/components/core/tree/TreeItem.tsx b/src-web/components/core/tree/TreeItem.tsx index 68b8b476..36fa7694 100644 --- a/src-web/components/core/tree/TreeItem.tsx +++ b/src-web/components/core/tree/TreeItem.tsx @@ -296,7 +296,7 @@ function TreeItemInner({ onClick={handleClick} onDoubleClick={handleDoubleClick} disabled={editing} - className="tree-item-inner px-2 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap" + className="cursor-default tree-item-inner px-2 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap" {...attributes} {...listeners} tabIndex={isLastSelected ? 0 : -1} diff --git a/src-web/lib/formatters.ts b/src-web/lib/formatters.ts index 9a97339a..096f5f52 100644 --- a/src-web/lib/formatters.ts +++ b/src-web/lib/formatters.ts @@ -1,12 +1,5 @@ -import XmlBeautify from 'xml-beautify'; import { invokeCmd } from './tauri'; -const INDENT = ' '; - -const xmlBeautifier = new XmlBeautify({ - INDENT, -}); - export async function tryFormatJson(text: string): Promise { if (text === '') return text; @@ -15,13 +8,12 @@ export async function tryFormatJson(text: string): Promise { return result; } catch (err) { console.warn('Failed to format JSON', err); - // Nothing } try { return JSON.stringify(JSON.parse(text), null, 2); } catch (err) { - console.log("JSON beautify failed", err); + console.log('JSON beautify failed', err); } return text; @@ -31,9 +23,11 @@ export async function tryFormatXml(text: string): Promise { if (text === '') return text; try { - return xmlBeautifier.beautify(text); + const result = await invokeCmd('cmd_format_xml', { text }); + return result; } catch (err) { - console.log("XML beautify failed", err); - return text; + console.warn('Failed to format XML', err); } + + return text; } diff --git a/src-web/lib/tauri.ts b/src-web/lib/tauri.ts index 124ea691..f98547fa 100644 --- a/src-web/lib/tauri.ts +++ b/src-web/lib/tauri.ts @@ -15,6 +15,7 @@ type TauriCmd = | 'cmd_dismiss_notification' | 'cmd_export_data' | 'cmd_format_json' + | 'cmd_format_xml' | 'cmd_get_http_authentication_config' | 'cmd_get_http_authentication_summaries' | 'cmd_get_sse_events'