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
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 = "prompthive"
version = "0.5.0"
version = "0.6.0"
edition = "2021"
rust-version = "1.89"
authors = ["Jory Pestorious <jory@pestorious.com>"]
Expand Down
109 changes: 55 additions & 54 deletions scripts/qa/bare_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,57 +9,58 @@
from terminal_session import TerminalChild, terminal_modes

binary = str(Path(sys.argv[1]).resolve())
for action, keys in [('show', b'\t\x1b[C\r'), ('edit', b'\t\x1b[C\x1b[C\r'), ('cancel', b'\t\x1b[C\x1b')]:
with tempfile.TemporaryDirectory(prefix='ph-actions-') as temp:
root = Path(temp)
prompt = root / 'store/prompts/review.md'
prompt.parent.mkdir(parents=True)
original = 'Review this safely.\n\x1b]52;c;YWJj\x07'
prompt.write_text(original)
editor = root / 'editor'
editor.write_text('#!/bin/sh\nprintf "Edited safely.\\n" > "$1"\n')
editor.chmod(0o700)
env = os.environ.copy()
env.update(PROMPTHIVE_BASE_DIR=str(root/'store'), TERM='xterm-256color', EDITOR=str(editor), NO_COLOR='1')
for k in ('DISPLAY', 'WAYLAND_DISPLAY', 'VISUAL'):
env.pop(k, None)
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 18, 40, 0, 0))
before = terminal_modes(slave)
child = TerminalChild([binary], slave=slave, stdin=slave, stdout=slave, stderr=slave, cwd=root, env=env)
output = bytearray()
sent = False
try:
deadline = time.monotonic() + 8
while child.poll() is None and time.monotonic() < deadline:
if select.select([master], [], [], .05)[0]:
output.extend(os.read(master, 65536))
if not sent and b'preview' in output:
os.write(master, keys)
sent = True
if child.poll() is None:
child.send_signal(signal.SIGKILL)
raise RuntimeError(f'{action}: timed out')
child.wait(timeout=2)
while select.select([master], [], [], 0)[0]:
try:
chunk = os.read(master, 65536)
if not chunk: break
output.extend(chunk)
except OSError:
break
restored = terminal_modes(slave) == before
if not sent or not restored:
raise AssertionError(f'{action}: sent={sent}, restored={restored}')
if action == 'show':
assert b'\x1b[?1049l' in output, 'alternate screen did not close'
shown = output.rsplit(b'\x1b[?1049l', 1)[1]
assert child.returncode == 0 and b'Review this safely.' in shown and prompt.read_text() == original
assert b'\x1b]52;' not in shown and b'\\u{1b}]52;c;YWJj\\u{7}' in shown, shown
elif action == 'edit':
assert child.returncode == 0 and 'Edited safely.' in prompt.read_text()
else:
assert child.returncode == 130 and prompt.read_text() == original
print(f'PASS {action}: exit={child.returncode}, terminal restored, expected fixture state')
finally:
os.close(master); os.close(slave); child.close()
for invocation in ([], ['tui'], ['tui', 'review']):
for action, keys in [('show', b'\t\x1b[C\r'), ('edit', b'\t\x1b[C\x1b[C\r'), ('cancel', b'\t\x1b[C\x1b')]:
with tempfile.TemporaryDirectory(prefix='ph-actions-') as temp:
root = Path(temp)
prompt = root / 'store/prompts/review.md'
prompt.parent.mkdir(parents=True)
original = 'Review this safely.\n\x1b]52;c;YWJj\x07'
prompt.write_text(original)
editor = root / 'editor'
editor.write_text('#!/bin/sh\nprintf "Edited safely.\\n" > "$1"\n')
editor.chmod(0o700)
env = os.environ.copy()
env.update(PROMPTHIVE_BASE_DIR=str(root/'store'), TERM='xterm-256color', EDITOR=str(editor), NO_COLOR='1')
for k in ('DISPLAY', 'WAYLAND_DISPLAY', 'VISUAL'):
env.pop(k, None)
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 18, 40, 0, 0))
before = terminal_modes(slave)
child = TerminalChild([binary, *invocation], slave=slave, stdin=slave, stdout=slave, stderr=slave, cwd=root, env=env)
output = bytearray()
sent = False
try:
deadline = time.monotonic() + 8
while child.poll() is None and time.monotonic() < deadline:
if select.select([master], [], [], .05)[0]:
output.extend(os.read(master, 65536))
if not sent and b'preview' in output:
os.write(master, keys)
sent = True
if child.poll() is None:
child.send_signal(signal.SIGKILL)
raise RuntimeError(f'{action}: timed out')
child.wait(timeout=2)
while select.select([master], [], [], 0)[0]:
try:
chunk = os.read(master, 65536)
if not chunk: break
output.extend(chunk)
except OSError:
break
restored = terminal_modes(slave) == before
if not sent or not restored:
raise AssertionError(f'{action}: sent={sent}, restored={restored}')
if action == 'show':
assert b'\x1b[?1049l' in output, 'alternate screen did not close'
shown = output.rsplit(b'\x1b[?1049l', 1)[1]
assert child.returncode == 0 and b'Review this safely.' in shown and prompt.read_text() == original
assert b'\x1b]52;' not in shown and b'\\u{1b}]52;c;YWJj\\u{7}' in shown, shown
elif action == 'edit':
assert child.returncode == 0 and 'Edited safely.' in prompt.read_text()
else:
assert child.returncode == 130 and prompt.read_text() == original
print(f'PASS {invocation or ["bare"]} {action}: exit={child.returncode}, terminal restored, expected fixture state')
finally:
os.close(master); os.close(slave); child.close()
56 changes: 30 additions & 26 deletions src/commands/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,33 @@ fn listing(prompts: &[Prompt], descriptions: bool) -> Result<String> {
}
Ok(text)
}
fn browse(storage: &Storage, input: &str, query: &str) -> Result<()> {
#[cfg(feature = "tui")]
{
use crate::tui::BareSelection;
match crate::tui::pick_bare(storage, input, query)?
.ok_or_else(|| fail(130, "Cancelled; no prompt was used."))?
{
BareSelection::Use(name, text) => {
IoOptions::default().emit(storage, &text, &name, None)
}
BareSelection::Show(name) => {
let (_, text) = storage.read_prompt(&name)?;
write_stdout(&safe_terminal_body(&text)?)
}
BareSelection::Edit(name) => {
crate::edit::edit_prompt(storage, &name)?;
eprintln!("Saved {name}.");
Ok(())
}
}
}
#[cfg(not(feature = "tui"))]
{
let _ = (storage, input, query);
unreachable!("interactive() is false without the tui feature")
}
}
pub fn run(command: Option<Commands>) -> Result<()> {
let start = Instant::now();
if command.is_none() {
Expand All @@ -204,29 +231,7 @@ pub fn run(command: Option<Commands>) -> Result<()> {
}
let storage = Storage::new()?;
storage.init()?;
#[cfg(feature = "tui")]
{
use crate::tui::BareSelection;
return match crate::tui::pick_bare(&storage, &input)?
.ok_or_else(|| fail(130, "Cancelled; no prompt was used."))?
{
BareSelection::Use(name, text) => {
IoOptions::default().emit(&storage, &text, &name, None)
}
BareSelection::Show(name) => {
let (_, text) = storage.read_prompt(&name)?;
let safe = safe_terminal_body(&text)?;
write_stdout(&safe)
}
BareSelection::Edit(name) => {
crate::edit::edit_prompt(&storage, &name)?;
eprintln!("Saved {name}.");
Ok(())
}
};
}
#[cfg(not(feature = "tui"))]
unreachable!("interactive() is false without the tui feature");
return browse(&storage, &input, "");
}
// Help/completions do not create or inspect a store.
if let Some(Commands::Completion { shell }) = command {
Expand Down Expand Up @@ -340,9 +345,8 @@ pub fn run(command: Option<Commands>) -> Result<()> {
let input=read_input(None)?;
if !interactive(){return Err(fail(2,"terminal preview is unavailable; use ph f QUERY then ph u EXACT_NAME"));}
storage.init()?;
// t is an explicit discovery request even when SEARCH is an exact name.
let (name,text)=render_use(&storage,search.as_deref(),None,&input,&Default::default(),|name|IoOptions::default().confirmation(name),true)?;
IoOptions::default().emit(&storage,&text,&name,None)
// Explicit tui always opens discovery, including for an exact search.
browse(&storage, &input, search.as_deref().unwrap_or(""))
}
Commands::Export{path}=>{
let count=crate::portability::export_store(&storage,std::path::Path::new(&path))?;
Expand Down
11 changes: 10 additions & 1 deletion src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,14 @@ impl Storage {
}

pub fn read_prompt(&self, name: &str) -> Result<(PromptMetadata, String)> {
let (metadata, body, _) = self.read_prompt_with_description_origin(name)?;
Ok((metadata, body))
}

pub(crate) fn read_prompt_with_description_origin(
&self,
name: &str,
) -> Result<(PromptMetadata, String, bool)> {
let bytes = self.read_raw(name)?;
let text = std::str::from_utf8(&bytes)
.with_context(|| format!("prompt {name} is not valid UTF-8"))?;
Expand All @@ -303,6 +311,7 @@ impl Storage {
if metadata.id.is_empty() {
metadata.id = name.to_string();
}
let has_description = !metadata.description.trim().is_empty();
if metadata.description.is_empty() {
metadata.description = body
.lines()
Expand All @@ -312,7 +321,7 @@ impl Storage {
.take(80)
.collect();
}
Ok((metadata, body))
Ok((metadata, body, has_description))
}

pub fn parse_prompt_content(&self, content: &str) -> Result<(PromptMetadata, String)> {
Expand Down
127 changes: 112 additions & 15 deletions src/tui/guided.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,35 @@ pub(super) fn preview_lines(
preview_page(text, width, skip, count, false).lines
}

/// Page a description and body without copying either complete string.
pub(super) fn detail_preview(
description: &str,
body: &str,
width: u16,
skip: usize,
count: usize,
scan_all: bool,
) -> (Vec<Line<'static>>, usize) {
let lines = description
.split('\n')
.map(|line| (line, true))
.chain([("", false), ("preview", false)])
.chain(body.split('\n').map(|line| (line, false)));
let page = preview_logical_lines(lines, width, skip, count, scan_all);
(page.lines, page.row)
}

pub(super) fn description_row_count(description: &str, width: u16) -> usize {
preview_logical_lines(
description.split('\n').map(|line| (line, true)),
width,
0,
0,
true,
)
.row
}

struct Page {
width: usize,
skip: usize,
Expand Down Expand Up @@ -462,6 +491,22 @@ impl Page {
}

fn preview_page(text: &str, width: u16, skip: usize, count: usize, scan_all: bool) -> Page {
preview_logical_lines(
text.split('\n').map(|line| (line, false)),
width,
skip,
count,
scan_all,
)
}

fn preview_logical_lines<'a>(
lines: impl Iterator<Item = (&'a str, bool)>,
width: u16,
skip: usize,
count: usize,
scan_all: bool,
) -> Page {
let mut page = Page {
width: usize::from(width),
skip,
Expand All @@ -473,27 +518,29 @@ fn preview_page(text: &str, width: u16, skip: usize, count: usize, scan_all: boo
line: String::new(),
lines: Vec::new(),
};
for logical in text.split('\n') {
// Span filters control graphemes; separate them first so tabs and CR
// are represented visibly rather than being silently dropped.
for segment in logical.split_inclusive(char::is_control) {
for (logical, is_description) in lines {
// Split at words and controls together so a long single line is not
// scanned to its end before yielding the first visible word.
for segment in logical.split_inclusive(|c: char| c.is_whitespace() || c.is_control()) {
let control = segment.chars().next_back().filter(|c| c.is_control());
let normal = &segment[..segment.len() - control.map_or(0, char::len_utf8)];
// Prefer breaking between words; exceptionally long words still
// wrap by grapheme. This changes display only, not rendered bytes.
for word in normal.split_inclusive(char::is_whitespace) {
let span = Span::raw(word);
if page.has_token && page.used + span.width() > page.width && page.flush() {
let span = Span::raw(normal);
if page.has_token && page.used + span.width() > page.width && page.flush() {
return page;
}
for grapheme in span.styled_graphemes(Style::default()) {
if page.push(grapheme.symbol) {
return page;
}
for grapheme in span.styled_graphemes(Style::default()) {
if page.push(grapheme.symbol) {
return page;
}
}
}
if let Some(control) = control {
let escaped = inline_text(&control.to_string());
let escaped = if is_description && control == '\t' {
" ".to_owned()
} else if is_description {
format!("\\u{{{:04x}}}", control as u32)
} else {
inline_text(&control.to_string())
};
for character in escaped.chars() {
if page.push(&character.to_string()) {
return page;
Expand Down Expand Up @@ -693,6 +740,56 @@ mod tests {
assert!(!page.iter().any(|line| line.to_string().contains('\u{1b}')));
}

#[test]
fn description_controls_are_safe_and_tabs_display_as_four_spaces() {
let description = "a\tb\rc\u{1b}d";
let safe = super::super::safe_text(description);
let combined = format!("{safe}\n\npreview\nbody");
for width in [10, 30] {
let (actual, _) = detail_preview(description, "body", width, 0, 30, true);
let (expected, _) = counted_preview(&combined, width, 0, 30);
let text = |lines: Vec<Line<'static>>| {
lines
.into_iter()
.map(|line| line.to_string())
.collect::<String>()
};
let actual = text(actual);
let expected = text(expected);
assert!(!actual.contains('\u{1b}'));
assert_eq!(
actual
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>(),
expected
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
);
if width == 30 {
assert!(actual.contains("a b"));
}
}
}

#[test]
fn multipart_detail_matches_combined_paging_without_copying_the_body() {
let description = "first line café\nsecond line";
let body = "long body ".repeat(100_000);
let combined = format!("{description}\n\npreview\n{body}");
for width in [38, 50] {
for skip in [0, 3, 20] {
let (actual, actual_total) =
detail_preview(description, &body, width, skip, 4, true);
let (expected, expected_total) = counted_preview(&combined, width, skip, 4);
assert_eq!(actual_total, expected_total);
assert_eq!(actual, expected);
assert!(actual.len() <= 4);
}
}
}

#[test]
fn counted_picker_preview_tracks_all_rows_but_keeps_only_a_page() {
let source = "\u{1b}".repeat(100_000);
Expand Down
Loading
Loading