Skip to content

Commit c9fdbd1

Browse files
author
lex
committed
feat(http): inline image previews
1 parent 713cd46 commit c9fdbd1

18 files changed

Lines changed: 957 additions & 101 deletions

File tree

LEARNINGS.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,11 @@ pitfall, log it here. Check this file before starting any task.
3131
- 2026-07-02: http/block-index — inter-block separator lines (after last content line of a block, before next `###`) must NOT be associated with any block. `find_request_block_bounds` returns nil for cursor on these lines. Fix: track `last_content_line` per block during scan, set `block.end_line = last_content_line`, exclude trailing empties/comments from block range. See `lua/poste/http/cache.lua:150-165`.
3232
- 2026-07-02: http/block-index — `detect_script_context` via line_type fails on buffers without `###` blocks because file-level area only classifies lines as `var` or `file`. Completion tests call `detect_script_context` on bare buffers (no `###`). Fix: add pre/post-script detection to file-level area in cache scanner. See `lua/poste/http/cache.lua:101-128`.
3333
- 2026-07-02: http/build_pending_request — `build_pending_request` used `buf_content:find("\n###", ...)` to find block end, which picks up inter-block comments and wrong content after var injection. Fix: pass `block_end` through callback chain from `execute_request``start_curl_job``build_pending_request`; replace `\n###` scan with block_line extraction using numeric line bounds. See `lua/poste/http/run.lua:166-242`.
34-
- 2026-07-04: http/file-include — `< file` expansion in `process_form_data` embeds raw file content into buffer content before Rust parser splits blocks on `###`. File content containing `###` at line start creates false block boundaries, truncating the request body. Fix: move `< file` expansion out of Lua `process_form_data` (now only does magic vars), into Rust `run.rs:resolve_file_includes` after `parse_at_line` so Rust parser never sees file content `###`. See `lua/poste/http/request_vars.lua:37-88` and `crates/poste-cli/src/run.rs:104-106`.
34+
- 2026-07-04: http/file-include — `< file` expansion in `process_form_data` embeds raw file content into buffer content before Rust parser splits blocks on `###`. File content containing `###` at line start creates false block boundaries, truncating the request body. Fix: move `< file` expansion out of Lua `process_form_data` (now only does magic vars), into Rust `run.rs:resolve_file_includes` after `parse_at_line` so Rust parser never sees file content `###`. See `lua/poste/http/request_vars.lua:37-88` and `crates/poste-cli/src/run.rs:104-106`.
35+
- 2026-07-04: http/image-preview — Kitty graphics protocol (`\033_Ga=T,f=100,m=0;BASE64\033\\`) doesn't work through Neovim's Lua `io.write()`. ESC (0x1b) bytes are stripped or mangled by Neovim's output pipeline (libvterm's stdout processing), causing raw base64 to appear as literal terminal text. `os.execute('printf ... > /dev/tty')` would bypass the pipeline, but it blocks the UI. `jobstart({stdout = 1})` inherit doesn't exist — Neovim always captures child stdout through a pipe. Fix: use system viewer (`open`/`xdg-open` via `jobstart`) which works everywhere. Kitty inline kept as best-effort commented attempt. See `lua/poste/http/format.lua:156-195`.
36+
- 2026-07-04: http/image-preview — terminal preview buffers created with fake/test buffers can break if code writes `vim.bo[buf]` directly after `nvim_open_term()`. Fix: use `nvim_set_option_value(..., { buf = buf })` for preview buffer options so the path works with both real buffers and mocked test buffers. See `lua/poste/http/format.lua:208-240`.
37+
- 2026-07-04: http/image-preview — `nvim_open_term()` is still an internal libvterm terminal, so kitty graphics output renders blank inside Neovim even in Kitty/WezTerm. Fix: render image previews as a small colored-cell buffer using ImageMagick sampling + extmark background highlights instead of trying to pass graphics escape codes through Neovim. See `lua/poste/http/format.lua:184-340`.
38+
- 2026-07-04: http/image-preview — colored-cell fallback is not a usable image preview. Fix: remove the built-in preview path, prefer `image.nvim` when installed, and otherwise fall back to opening the file externally. See `lua/poste/http/format.lua:232-305` and `lua/poste/http/buffer.lua:267-275`.
39+
- 2026-07-04: http/binary-upload — `< file.png` inline expanded via `String::from_utf8_lossy` corrupts binary data, and null bytes in `--data-binary <inline>` arg fail with "nul byte found in provided data" (OS rejects NUL in argv). Fix: `Request.body` changed from `String` to `Vec<u8>`, `resolve_file_includes` preserves raw bytes, executor writes body to tempfile and uses `--data-binary @path`. See `crates/poste-cli/src/run.rs:203-239`, `crates/poste-exec/src/executor.rs:117-122`.
40+
- 2026-07-04: http/image-preview — `image.nvim` can render against the existing response buffer/window; a separate preview popup is unnecessary. Fix: body view now auto-renders inline when `image.nvim` is installed, and `K` only falls back to external open when inline render is unavailable. See `lua/poste/http/format.lua:195-300` and `lua/poste/http/view.lua:94-151`.
41+
- 2026-07-04: http/image-preview — `image.nvim` inline render follows the current cursor/anchor, so rendering at line 1 overlays metadata text. Fix: reserve real blank lines after the binary metadata block and move the cursor to the first blank line before calling `image.from_file(...):render()`. See `lua/poste/http/format.lua:155-236` and `lua/poste/http/view.lua:159-170`.

crates/poste-cli/src/run.rs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,11 @@ pub async fn execute(args: RunArgs) -> Result<()> {
104104
// Save raw body AFTER variable substitution but BEFORE file include
105105
// resolution — so Verbose/Rqst tabs show `{{host}}` expanded but
106106
// `< ./photo.png` kept as-is (not dumping binary content).
107-
request.raw_body = request.body.clone();
107+
request.raw_body = request.body_str().to_string();
108108

109109
// Expand < file directives in the body (must happen after parsing so that
110110
// ### in file content doesn't corrupt block boundary detection).
111-
request.body = resolve_file_includes(&request.body, &search_dir)?;
111+
request.body = resolve_file_includes(request.body_str(), &search_dir)?;
112112

113113
// Resolve connection name for SQL protocols
114114
if crate::util::is_sql_protocol(&request.protocol)
@@ -200,32 +200,31 @@ pub async fn execute(args: RunArgs) -> Result<()> {
200200
/// - absolute paths
201201
///
202202
/// On read error the original line is kept (same as Lua behavior).
203-
fn resolve_file_includes(body: &str, base_dir: &Path) -> Result<String> {
203+
fn resolve_file_includes(body: &str, base_dir: &Path) -> Result<Vec<u8>> {
204204
let home = std::env::var("HOME").ok();
205-
let mut result = String::with_capacity(body.len());
205+
let mut result = Vec::with_capacity(body.len());
206206
for line in body.lines() {
207207
let trimmed = line.trim();
208208
if let Some(path_str) = trimmed.strip_prefix("< ") {
209209
let resolved = resolve_include_path(path_str.trim(), base_dir, &home);
210210
match std::fs::read(&resolved) {
211211
Ok(bytes) => {
212-
let content = String::from_utf8_lossy(&bytes);
213-
result.push_str(&content);
212+
result.extend_from_slice(&bytes);
214213
// file content includes its own newlines; don't add another
215214
}
216215
Err(_) => {
217216
// keep original line on error
218-
result.push_str(line);
219-
result.push('\n');
217+
result.extend_from_slice(line.as_bytes());
218+
result.push(b'\n');
220219
}
221220
}
222221
} else {
223-
result.push_str(line);
224-
result.push('\n');
222+
result.extend_from_slice(line.as_bytes());
223+
result.push(b'\n');
225224
}
226225
}
227226
// Preserve trailing-newline semantics of the original body
228-
if !body.is_empty() && !body.ends_with('\n') && result.ends_with('\n') {
227+
if !body.is_empty() && !body.ends_with('\n') && result.last() == Some(&b'\n') {
229228
result.pop();
230229
}
231230
Ok(result)

crates/poste-core/src/parser.rs

Lines changed: 39 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ impl Parser {
212212
name,
213213
protocol,
214214
connection,
215-
body,
215+
body: body.into_bytes(),
216216
raw_body: String::new(), // filled by CLI after resolve_file_includes
217217
})
218218
}
@@ -591,8 +591,8 @@ Authorization: Bearer {{api_key}}
591591
let request = parser
592592
.parse_block(block, Protocol::Http, &HashMap::new())
593593
.unwrap();
594-
assert!(request.body.contains("GET /users/123"));
595-
assert!(request.body.contains("Authorization: Bearer secret"));
594+
assert!(request.body_str().contains("GET /users/123"));
595+
assert!(request.body_str().contains("Authorization: Bearer secret"));
596596
}
597597

598598
#[test]
@@ -614,7 +614,7 @@ GET http://{{host}}:{{port}}/{{timeout}}
614614
let request = parser.parse_at_line(content, 6, "http").unwrap();
615615

616616
// host should be from request_vars (highest priority)
617-
assert!(request.body.contains("http://request.com:8080/30"));
617+
assert!(request.body_str().contains("http://request.com:8080/30"));
618618
}
619619

620620
#[test]
@@ -624,9 +624,9 @@ GET http://{{host}}:{{port}}/{{timeout}}
624624
let request = parser
625625
.parse_block(block, Protocol::Http, &HashMap::new())
626626
.unwrap();
627-
assert!(request.body.contains("GET /api/data"));
628-
assert!(!request.body.contains("{%"));
629-
assert!(!request.body.contains("local x"));
627+
assert!(request.body_str().contains("GET /api/data"));
628+
assert!(!request.body_str().contains("{%"));
629+
assert!(!request.body_str().contains("local x"));
630630
}
631631

632632
#[test]
@@ -637,8 +637,8 @@ GET http://{{host}}:{{port}}/{{timeout}}
637637
let request = parser
638638
.parse_block(block, Protocol::Http, &HashMap::new())
639639
.unwrap();
640-
assert!(request.body.contains("GET /api/data"));
641-
assert!(!request.body.contains("{%"));
640+
assert!(request.body_str().contains("GET /api/data"));
641+
assert!(!request.body_str().contains("{%"));
642642
}
643643

644644
#[test]
@@ -648,8 +648,8 @@ GET http://{{host}}:{{port}}/{{timeout}}
648648
let request = parser
649649
.parse_block(block, Protocol::Http, &HashMap::new())
650650
.unwrap();
651-
assert!(request.body.contains("GET /api/data"));
652-
assert!(!request.body.contains("gen.lua"));
651+
assert!(request.body_str().contains("GET /api/data"));
652+
assert!(!request.body_str().contains("gen.lua"));
653653
}
654654

655655
#[test]
@@ -659,17 +659,17 @@ GET http://{{host}}:{{port}}/{{timeout}}
659659
let request = parser
660660
.parse_block(block, Protocol::Http, &HashMap::new())
661661
.unwrap();
662-
assert!(request.body.contains("GET /api/data"));
663-
assert!(!request.body.contains("check.lua"));
662+
assert!(request.body_str().contains("GET /api/data"));
663+
assert!(!request.body_str().contains("check.lua"));
664664
}
665665

666666
#[test]
667667
fn test_assertion_external_stripped_multi_block() {
668668
let parser = Parser::new(HashMap::new());
669669
let content = "### Request 1\nGET /api/data\n> ./scripts/check.lua\n\n### Request 2\nGET /api/other\n";
670670
let request = parser.parse_at_line(content, 2, "http").unwrap();
671-
assert!(request.body.contains("GET /api/data"));
672-
assert!(!request.body.contains("check.lua"));
671+
assert!(request.body_str().contains("GET /api/data"));
672+
assert!(!request.body_str().contains("check.lua"));
673673
}
674674

675675
#[test]
@@ -679,7 +679,7 @@ GET http://{{host}}:{{port}}/{{timeout}}
679679
let request = parser
680680
.parse_block(block, Protocol::Http, &HashMap::new())
681681
.unwrap();
682-
assert!(request.body.contains("GET /api?token=injected-value"));
682+
assert!(request.body_str().contains("GET /api?token=injected-value"));
683683
}
684684

685685
// ---- @var enhancements: quote stripping, {{var}} in values, multi-line blocks ----
@@ -720,7 +720,7 @@ GET /api?{{page}}
720720
assert_eq!(vars.get("page"), Some(&"pageNum=1&pageSize=10".to_string()));
721721

722722
let req = parser.parse_at_line(content, 5, "http").unwrap();
723-
assert!(req.body.contains("GET /api?pageNum=1&pageSize=10"));
723+
assert!(req.body_str().contains("GET /api?pageNum=1&pageSize=10"));
724724
}
725725

726726
#[test]
@@ -739,9 +739,9 @@ POST /api/data
739739
{"key": "value"}
740740
"#;
741741
let req = parser.parse_at_line(content, 8, "http").unwrap();
742-
assert!(req.body.contains("Authorization: abc123"));
743-
assert!(req.body.contains("X-Custom: yes"));
744-
assert!(req.body.contains("{\"key\": \"value\"}"));
742+
assert!(req.body_str().contains("Authorization: abc123"));
743+
assert!(req.body_str().contains("X-Custom: yes"));
744+
assert!(req.body_str().contains("{\"key\": \"value\"}"));
745745
}
746746

747747
#[test]
@@ -755,8 +755,8 @@ POST /api/data
755755
GET /{{page}}
756756
"#;
757757
let req = parser.parse_at_line(content, 4, "http").unwrap();
758-
assert!(req.body.contains("GET /id=99"));
759-
assert!(!req.body.contains("{{pageNum}}"));
758+
assert!(req.body_str().contains("GET /id=99"));
759+
assert!(!req.body_str().contains("{{pageNum}}"));
760760
}
761761

762762
#[test]
@@ -769,7 +769,7 @@ GET /{{page}}
769769
GET {{path}}
770770
"#;
771771
let req = parser.parse_at_line(content, 5, "http").unwrap();
772-
assert!(req.body.contains("GET /api/v1/users"));
772+
assert!(req.body_str().contains("GET /api/v1/users"));
773773
}
774774

775775
#[test]
@@ -787,9 +787,9 @@ POST /api/data
787787
let req = parser
788788
.parse_block(block, Protocol::Http, &HashMap::new())
789789
.unwrap();
790-
assert!(req.body.contains("POST /api/data"));
791-
assert!(req.body.contains("Authorization: secret"));
792-
assert!(req.body.contains("Content-Type: application/json"));
790+
assert!(req.body_str().contains("POST /api/data"));
791+
assert!(req.body_str().contains("Authorization: secret"));
792+
assert!(req.body_str().contains("Content-Type: application/json"));
793793
}
794794

795795
#[test]
@@ -803,8 +803,8 @@ GET /api
803803
Authorization: {{token}}
804804
"#;
805805
let req = parser.parse_at_line(content, 5, "http").unwrap();
806-
assert!(req.body.contains("Authorization: secret"));
807-
assert!(!req.body.contains("{{admin_token}}"));
806+
assert!(req.body_str().contains("Authorization: secret"));
807+
assert!(!req.body_str().contains("{{admin_token}}"));
808808
}
809809

810810
#[test]
@@ -821,8 +821,8 @@ GET /api
821821
Authorization: {{token}}
822822
"#;
823823
let req = parser.parse_at_line(content, 5, "http").unwrap();
824-
assert!(req.body.contains("Authorization: secret"));
825-
assert!(!req.body.contains("{{admin_token}}"));
824+
assert!(req.body_str().contains("Authorization: secret"));
825+
assert!(!req.body_str().contains("{{admin_token}}"));
826826
}
827827

828828
#[test]
@@ -837,7 +837,7 @@ Authorization: {{token}}
837837
let req = parser
838838
.parse_block(block, Protocol::Http, &HashMap::new())
839839
.unwrap();
840-
assert!(req.body.contains("Authorization: secret"));
840+
assert!(req.body_str().contains("Authorization: secret"));
841841
}
842842

843843
#[test]
@@ -888,13 +888,13 @@ Authorization: {{token}}
888888
let parser = Parser::new(HashMap::new());
889889
let content = "### Request 1\nGET /api/one\n\n> {% client.test(\"a\", function() end) %}\n\n# ─────────────────\n# Comment between blocks\n# ─────────────────\n\n### Request 2\nGET /api/two\n";
890890
let request = parser.parse_at_line(content, 2, "http").unwrap();
891-
assert!(request.body.contains("GET /api/one"));
891+
assert!(request.body_str().contains("GET /api/one"));
892892
assert!(
893-
!request.body.contains("Comment between blocks"),
893+
!request.body_str().contains("Comment between blocks"),
894894
"body should not contain inter-block comments"
895895
);
896896
assert!(
897-
!request.body.contains("──"),
897+
!request.body_str().contains("──"),
898898
"body should not contain inter-block comment decorations"
899899
);
900900
}
@@ -906,10 +906,10 @@ Authorization: {{token}}
906906
let request = parser
907907
.parse_block(content, Protocol::Http, &HashMap::new())
908908
.unwrap();
909-
assert!(!request.body.contains("{{$timestamp}}"));
910-
assert!(!request.body.contains("{{$uuid}}"));
911-
assert!(request.body.contains("\"ts\": \""));
912-
assert!(request.body.contains("\"uuid\": \""));
909+
assert!(!request.body_str().contains("{{$timestamp}}"));
910+
assert!(!request.body_str().contains("{{$uuid}}"));
911+
assert!(request.body_str().contains("\"ts\": \""));
912+
assert!(request.body_str().contains("\"uuid\": \""));
913913
}
914914

915915
#[test]
@@ -924,7 +924,7 @@ X-Val: {{a}}
924924
"#;
925925
// Should not hang or panic — caps at 20 iterations
926926
let req = parser.parse_at_line(content, 5, "http").unwrap();
927-
let body = req.body;
927+
let body = req.body_str();
928928
assert!(body.contains("X-Val: {{b}}") || body.contains("X-Val: {{a}}"));
929929
}
930930
}

crates/poste-core/src/request.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,19 @@ pub struct Request {
1515
pub protocol: Protocol,
1616
pub connection: String,
1717
/// Resolved body after file includes (`< filename`) and magic vars are expanded.
18-
/// This is what gets sent as the HTTP request body (via curl --data-binary).
19-
pub body: String,
18+
/// Raw bytes — binary-safe for HTTP file uploads.
19+
pub body: Vec<u8>,
2020
/// Original body before file include resolution, for display in the request
21-
/// preview / Verbose tab. If empty, falls back to `body`.
21+
/// preview / Verbose tab. If empty, falls back to `body` converted to string.
2222
pub raw_body: String,
2323
}
2424

25+
impl Request {
26+
pub fn body_str(&self) -> &str {
27+
std::str::from_utf8(&self.body).unwrap_or("")
28+
}
29+
}
30+
2531
/// Replace the database name in a connection URL.
2632
/// "postgres://user:pass@host:5432/olddb" → "postgres://user:pass@host:5432/newdb"
2733
/// Handles URLs with or without auth, port, and existing database.

crates/poste-core/src/sql_parser.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ pub struct SqlParseResult {
2222
/// The body has already been through variable substitution in `parser.rs`,
2323
/// so `{{var}}` references are already resolved.
2424
pub fn parse_sql_request(request: &Request) -> Result<SqlParseResult> {
25-
let database = extract_database(&request.body);
26-
let statements = split_statements(&request.body);
25+
let database = extract_database(request.body_str());
26+
let statements = split_statements(request.body_str());
2727

2828
Ok(SqlParseResult {
2929
connection: request.connection.clone(),
@@ -196,7 +196,7 @@ mod tests {
196196
name: Some("test".to_string()),
197197
protocol: Protocol::Postgres,
198198
connection: "postgres://localhost/test".to_string(),
199-
body: body.to_string(),
199+
body: body.to_string().into_bytes(),
200200
raw_body: body.to_string(),
201201
}
202202
}

0 commit comments

Comments
 (0)