markdown: Add ability to wrap/unwrap codeblock content (#57312)

This PR adds an icon button to Markdown codeblocks allowing to control
whether or not the content should be wrapped. At the moment, this is not
hard-persisted, meaning that 1) wrapping text in one codeblock instance
does not affect others, and 2) the codeblock will be reset every time
its view is recreated (i.e., closing and opening a Markdown Preview tab,
an agent thread, etc.). I intentionally kept it simple just to see how
it feels, but we can certainly consider a setting later on.

| Unwrapping | Wrapping |
|--------|--------|
| <img width="782" height="658" alt="Screenshot 2026-05-20 at 5  09
2@2x"
src="https://github.com/user-attachments/assets/e9151e91-32ba-40d4-9c65-535dec309291"
/> | <img width="736" height="604" alt="Screenshot 2026-05-20 at 5 
09@2x"
src="https://github.com/user-attachments/assets/157db6fd-ec4c-4c96-b44a-119273cbd0f9"
/> |

Release Notes:

- Added the ability to control codeblock content wrapping through the
UI.
This commit is contained in:
Danilo Leal 2026-05-20 17:51:56 -03:00 committed by GitHub
parent 2b506483b2
commit 0042fb5850
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 142 additions and 26 deletions

View file

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.748 5.74794L14 7.99998L11.748 10.252" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 7.99997L3 7.99997" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 3L8 6" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 10L8 13" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 566 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.19924 8.90081L2.9472 6.64876L5.19924 4.39673" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.9472 6.64877H7.67649C8.00178 6.64877 8.32391 6.71285 8.6245 6.83734C8.92501 6.96184 9.19813 7.14431 9.42817 7.37434C9.6582 7.60437 9.84067 7.87746 9.96512 8.17802C10.0897 8.47856 10.1537 8.8007 10.1537 9.12601C10.1537 9.45131 10.0897 9.77345 9.96512 10.074C9.84067 10.3745 9.6582 10.6477 9.42817 10.8777C9.19813 11.1077 8.92501 11.2902 8.6245 11.4147C8.32391 11.5392 8.00178 11.6033 7.67649 11.6033H6.10005" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.0528 3.33331V12.6666" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 871 B

View file

@ -508,6 +508,7 @@ impl AcpTools {
} else {
CopyButtonVisibility::Hidden
},
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
},
),

View file

@ -3133,6 +3133,11 @@ fn render_agent_markdown(
})
.unwrap_or_default();
MarkdownElement::new(markdown, style)
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: markdown::CopyButtonVisibility::VisibleOnHover,
wrap_button_visibility: markdown::WrapButtonVisibility::VisibleOnHover,
border: false,
})
.image_resolver(move |dest_url| resolve_agent_image(dest_url, &worktree_roots))
.on_url_click(move |text, window, cx| {
thread_view::open_link(text, &workspace, window, cx);

View file

@ -5991,6 +5991,7 @@ impl ThreadView {
.render_markdown(command, style, cx)
.code_block_renderer(CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
});
let copy_button = CopyButton::new("copy-command", command_text)

View file

@ -240,6 +240,7 @@ impl DiagnosticBlock {
)
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
})
.on_url_click({

View file

@ -1260,6 +1260,7 @@ impl CompletionsMenu {
MarkdownElement::new(markdown, hover_markdown_style(window, cx))
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
})
.on_url_click(open_markdown_url),

View file

@ -1043,6 +1043,7 @@ impl InfoPopover {
MarkdownElement::new(markdown, hover_markdown_style(window, cx))
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
})
.on_url_click(open_markdown_url)
@ -1157,6 +1158,7 @@ impl DiagnosticPopover {
)
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
})
.on_url_click(

View file

@ -409,6 +409,8 @@ impl SignatureHelpPopover {
)
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility:
markdown::WrapButtonVisibility::Hidden,
border: false,
})
.on_url_click(open_markdown_url),
@ -421,6 +423,8 @@ impl SignatureHelpPopover {
MarkdownElement::new(description, hover_markdown_style(window, cx))
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility:
markdown::WrapButtonVisibility::Hidden,
border: false,
})
.on_url_click(open_markdown_url),

View file

@ -239,6 +239,8 @@ pub enum IconName {
Terminal,
TerminalAlt,
TextSnippet,
TextWrap,
TextUnwrap,
ThinkingMode,
ThinkingModeOff,
Thread,

View file

@ -561,6 +561,8 @@ mod tests {
});
}
use crate::WrapButtonVisibility;
fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText {
struct TestWindow;
@ -582,6 +584,7 @@ mod tests {
MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: WrapButtonVisibility::Hidden,
border: false,
},
)
@ -642,6 +645,7 @@ mod tests {
MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: WrapButtonVisibility::Hidden,
border: false,
},
)

View file

@ -334,6 +334,7 @@ pub struct Markdown {
mermaid_state: MermaidState,
mermaid_showing_code: HashSet<usize>,
copied_code_blocks: HashSet<ElementId>,
wrapped_code_blocks: HashSet<usize>,
code_block_scroll_handles: BTreeMap<usize, ScrollHandle>,
context_menu_link: Option<SharedString>,
context_menu_selected_text: Option<String>,
@ -356,9 +357,17 @@ pub enum CopyButtonVisibility {
VisibleOnHover,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WrapButtonVisibility {
Hidden,
AlwaysVisible,
VisibleOnHover,
}
pub enum CodeBlockRenderer {
Default {
copy_button_visibility: CopyButtonVisibility,
wrap_button_visibility: WrapButtonVisibility,
border: bool,
},
Custom {
@ -505,6 +514,7 @@ impl Markdown {
mermaid_state: MermaidState::default(),
mermaid_showing_code: HashSet::default(),
copied_code_blocks: HashSet::default(),
wrapped_code_blocks: HashSet::default(),
code_block_scroll_handles: BTreeMap::default(),
context_menu_link: None,
context_menu_selected_text: None,
@ -528,6 +538,16 @@ impl Markdown {
)
}
fn is_code_block_wrapped(&self, id: usize) -> bool {
self.wrapped_code_blocks.contains(&id)
}
fn toggle_code_block_wrap(&mut self, id: usize) {
if !self.wrapped_code_blocks.remove(&id) {
self.wrapped_code_blocks.insert(id);
}
}
fn code_block_scroll_handle(&mut self, id: usize) -> ScrollHandle {
self.code_block_scroll_handles
.entry(id)
@ -1073,6 +1093,7 @@ impl MarkdownElement {
style,
code_block_renderer: CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::VisibleOnHover,
wrap_button_visibility: WrapButtonVisibility::Hidden,
border: false,
},
on_url_click: None,
@ -1891,11 +1912,18 @@ impl Element for MarkdownElement {
parent_container.style().refine(&self.style.code_block);
builder.push_div(parent_container, range, markdown_end);
let is_wrapped =
self.markdown.read(cx).is_code_block_wrapped(range.start);
let code_block = div()
.id(("code-block", range.start))
.rounded_lg()
.map(|mut code_block| {
if let Some(scroll_handle) = scroll_handle.as_ref() {
if is_wrapped {
code_block.w_full()
} else if let Some(scroll_handle) =
scroll_handle.as_ref()
{
code_block.style().restrict_scroll_to_axis =
Some(true);
code_block
@ -2134,10 +2162,14 @@ impl Element for MarkdownElement {
if let CodeBlockRenderer::Default {
copy_button_visibility,
wrap_button_visibility,
..
} = &self.code_block_renderer
&& *copy_button_visibility != CopyButtonVisibility::Hidden
&& (*copy_button_visibility != CopyButtonVisibility::Hidden
|| *wrap_button_visibility != WrapButtonVisibility::Hidden)
{
let copy_button_visibility = *copy_button_visibility;
let wrap_button_visibility = *wrap_button_visibility;
builder.modify_current_div(|el| {
let content_range = parser::extract_code_block_content_range(
&parsed_markdown.source()[range.clone()],
@ -2146,28 +2178,48 @@ impl Element for MarkdownElement {
..content_range.end + range.start;
let code = parsed_markdown.source()[content_range].to_string();
let codeblock = render_copy_code_block_button(
range.end,
code,
self.markdown.clone(),
);
el.child(
h_flex()
.w_4()
.absolute()
.justify_end()
.when_else(
*copy_button_visibility
== CopyButtonVisibility::VisibleOnHover,
|this| {
this.top_0()
.right_0()
.visible_on_hover("code_block")
},
|this| this.top_1p5().right_1p5(),
)
.child(codeblock),
)
let any_hover = copy_button_visibility
== CopyButtonVisibility::VisibleOnHover
|| wrap_button_visibility
== WrapButtonVisibility::VisibleOnHover;
let any_always = copy_button_visibility
== CopyButtonVisibility::AlwaysVisible
|| wrap_button_visibility
== WrapButtonVisibility::AlwaysVisible;
let use_hover = any_hover && !any_always;
let mut button_row = h_flex()
.gap_0p5()
.absolute()
.bg(cx.theme().colors().editor_background)
.when_else(
use_hover,
|this| {
this.top_1().right_1().visible_on_hover("code_block")
},
|this| this.top_1p5().right_1p5(),
);
if wrap_button_visibility != WrapButtonVisibility::Hidden {
let is_wrapped =
self.markdown.read(cx).is_code_block_wrapped(range.start);
button_row = button_row.child(render_wrap_code_block_button(
range.start,
is_wrapped,
self.markdown.clone(),
));
}
if copy_button_visibility != CopyButtonVisibility::Hidden {
button_row = button_row.child(render_copy_code_block_button(
range.end,
code,
self.markdown.clone(),
));
}
el.child(button_row)
});
}
@ -2418,6 +2470,29 @@ fn apply_heading_style(
heading
}
fn render_wrap_code_block_button(
id: usize,
is_wrapped: bool,
markdown: Entity<Markdown>,
) -> impl IntoElement {
let (icon, tooltip) = if is_wrapped {
(IconName::TextUnwrap, "Unwrap Content")
} else {
(IconName::TextWrap, "Wrap Content")
};
IconButton::new(("wrap-code-block", id), icon)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text(tooltip))
.on_click(move |_event, _window, cx| {
markdown.update(cx, |markdown, cx| {
markdown.toggle_code_block_wrap(id);
cx.notify();
});
})
}
fn render_copy_code_block_button(
id: usize,
code: String,
@ -3434,6 +3509,7 @@ mod tests {
MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: WrapButtonVisibility::Hidden,
border: false,
},
)

View file

@ -585,7 +585,7 @@ mod tests {
};
use crate::{
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions,
MarkdownStyle,
MarkdownStyle, WrapButtonVisibility,
};
use collections::HashMap;
use gpui::{Context, Hsla, IntoElement, Render, RenderImage, TestAppContext, Window, size};
@ -644,6 +644,7 @@ mod tests {
MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: WrapButtonVisibility::Hidden,
border: false,
},
)
@ -924,6 +925,7 @@ mod tests {
MarkdownElement::new(markdown.clone(), MarkdownStyle::default())
.code_block_renderer(CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: WrapButtonVisibility::Hidden,
border: false,
})
},

View file

@ -623,6 +623,7 @@ impl MarkdownPreviewView {
let mut markdown_element = MarkdownElement::new(self.markdown.clone(), markdown_style)
.code_block_renderer(CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::VisibleOnHover,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
})
.scroll_handle(self.scroll_handle.clone())

View file

@ -402,6 +402,7 @@ impl Render for LanguageServerPrompt {
.text_size(TextSize::Small.rems(cx))
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
border: false,
})
.on_url_click(|link, _, cx| cx.open_url(&link)),

View file

@ -12,7 +12,10 @@ use gpui::{
StyleRefinement, Task, TextStyleRefinement, Window, list, prelude::*,
};
use language::LanguageRegistry;
use markdown::{CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle};
use markdown::{
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle,
WrapButtonVisibility,
};
use project::Project;
use settings::Settings;
use telemetry_events::{Event, EventWrapper};
@ -429,6 +432,7 @@ impl TelemetryLogView {
} else {
CopyButtonVisibility::Hidden
},
wrap_button_visibility: WrapButtonVisibility::Hidden,
border: false,
}),
),