Skip to content

Commit 1723713

Browse files
thataboymrnuggetbennetbo
authored
Add ability to copy assistant code block to clipboard or insert into editor, without manual selection (#17853)
Some notes: - You can put the cursor on the start or end line with triple backticks, it doesn't actually have to be inside the block. - Placing the cursor outside of a code block does nothing. - Code blocks are determined by counting triple backticks pairs from either start or end of buffer, and nothing else. - If you manually select something, the selection takes precedence over any code blocks. Release Notes: - Added the ability to copy surrounding code blocks in the assistant panel into the clipboard, or inserting them directly into the editor, without manually selecting. Place cursor anywhere in a code block (marked by triple backticks) and use the `assistant::CopyCode` action (`cmd-k c` / `ctrl-k c`) to copy to the clipboard, or the `assistant::InsertIntoEditor` action (`cmd-<` / `ctrl-<`) to insert into editor. --------- Co-authored-by: Thorsten Ball <mrnugget@gmail.com> Co-authored-by: Bennet <bennet@zed.dev>
1 parent ca4980d commit 1723713

File tree

6 files changed

+207
-18
lines changed

6 files changed

+207
-18
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

assets/keymaps/default-linux.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@
166166
{
167167
"context": "AssistantPanel",
168168
"bindings": {
169+
"ctrl-k c": "assistant::CopyCode",
169170
"ctrl-g": "search::SelectNextMatch",
170171
"ctrl-shift-g": "search::SelectPrevMatch",
171172
"alt-m": "assistant::ToggleModelSelector",

assets/keymaps/default-macos.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
{
189189
"context": "AssistantPanel",
190190
"bindings": {
191+
"cmd-k c": "assistant::CopyCode",
191192
"cmd-g": "search::SelectNextMatch",
192193
"cmd-shift-g": "search::SelectPrevMatch",
193194
"alt-m": "assistant::ToggleModelSelector",

crates/assistant/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,11 @@ editor = { workspace = true, features = ["test-support"] }
9494
env_logger.workspace = true
9595
language = { workspace = true, features = ["test-support"] }
9696
language_model = { workspace = true, features = ["test-support"] }
97+
languages = { workspace = true, features = ["test-support"] }
9798
log.workspace = true
9899
project = { workspace = true, features = ["test-support"] }
99100
rand.workspace = true
100101
serde_json_lenient.workspace = true
101102
text = { workspace = true, features = ["test-support"] }
103+
tree-sitter-md.workspace = true
102104
unindent.workspace = true

crates/assistant/src/assistant.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ actions!(
5858
[
5959
Assist,
6060
Split,
61+
CopyCode,
6162
CycleMessageRole,
6263
QuoteSelection,
6364
InsertIntoEditor,

crates/assistant/src/assistant_panel.rs

Lines changed: 200 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ use crate::{
1212
slash_command_picker,
1313
terminal_inline_assistant::TerminalInlineAssistant,
1414
Assist, CacheStatus, ConfirmCommand, Content, Context, ContextEvent, ContextId, ContextStore,
15-
ContextStoreEvent, CycleMessageRole, DeployHistory, DeployPromptLibrary, InlineAssistId,
16-
InlineAssistant, InsertDraggedFiles, InsertIntoEditor, Message, MessageId, MessageMetadata,
17-
MessageStatus, ModelPickerDelegate, ModelSelector, NewContext, PendingSlashCommand,
18-
PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata, SavedContextMetadata, Split,
19-
ToggleFocus, ToggleModelSelector, WorkflowStepResolution,
15+
ContextStoreEvent, CopyCode, CycleMessageRole, DeployHistory, DeployPromptLibrary,
16+
InlineAssistId, InlineAssistant, InsertDraggedFiles, InsertIntoEditor, Message, MessageId,
17+
MessageMetadata, MessageStatus, ModelPickerDelegate, ModelSelector, NewContext,
18+
PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata,
19+
SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector, WorkflowStepResolution,
2020
};
2121
use anyhow::{anyhow, Result};
2222
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection};
@@ -45,7 +45,8 @@ use gpui::{
4545
};
4646
use indexed_docs::IndexedDocsStore;
4747
use language::{
48-
language_settings::SoftWrap, Capability, LanguageRegistry, LspAdapterDelegate, Point, ToOffset,
48+
language_settings::SoftWrap, BufferSnapshot, Capability, LanguageRegistry, LspAdapterDelegate,
49+
ToOffset,
4950
};
5051
use language_model::{
5152
provider::cloud::PROVIDER_ID, LanguageModelProvider, LanguageModelProviderId,
@@ -56,6 +57,7 @@ use multi_buffer::MultiBufferRow;
5657
use picker::{Picker, PickerDelegate};
5758
use project::lsp_store::LocalLspAdapterDelegate;
5859
use project::{Project, Worktree};
60+
use rope::Point;
5961
use search::{buffer_search::DivRegistrar, BufferSearchBar};
6062
use serde::{Deserialize, Serialize};
6163
use settings::{update_settings_file, Settings};
@@ -81,9 +83,10 @@ use util::{maybe, ResultExt};
8183
use workspace::{
8284
dock::{DockPosition, Panel, PanelEvent},
8385
item::{self, FollowableItem, Item, ItemHandle},
86+
notifications::NotificationId,
8487
pane::{self, SaveIntent},
8588
searchable::{SearchEvent, SearchableItem},
86-
DraggedSelection, Pane, Save, ShowConfiguration, ToggleZoom, ToolbarItemEvent,
89+
DraggedSelection, Pane, Save, ShowConfiguration, Toast, ToggleZoom, ToolbarItemEvent,
8790
ToolbarItemLocation, ToolbarItemView, Workspace,
8891
};
8992
use workspace::{searchable::SearchableItemHandle, DraggedTab};
@@ -105,6 +108,7 @@ pub fn init(cx: &mut AppContext) {
105108
.register_action(AssistantPanel::inline_assist)
106109
.register_action(ContextEditor::quote_selection)
107110
.register_action(ContextEditor::insert_selection)
111+
.register_action(ContextEditor::copy_code)
108112
.register_action(ContextEditor::insert_dragged_files)
109113
.register_action(AssistantPanel::show_configuration)
110114
.register_action(AssistantPanel::create_new_context);
@@ -3100,6 +3104,40 @@ impl ContextEditor {
31003104
});
31013105
}
31023106

3107+
/// Returns either the selected text, or the content of the Markdown code
3108+
/// block surrounding the cursor.
3109+
fn get_selection_or_code_block(
3110+
context_editor_view: &View<ContextEditor>,
3111+
cx: &mut ViewContext<Workspace>,
3112+
) -> Option<(String, bool)> {
3113+
let context_editor = context_editor_view.read(cx).editor.read(cx);
3114+
3115+
if context_editor.selections.newest::<Point>(cx).is_empty() {
3116+
let snapshot = context_editor.buffer().read(cx).snapshot(cx);
3117+
let (_, _, snapshot) = snapshot.as_singleton()?;
3118+
3119+
let head = context_editor.selections.newest::<Point>(cx).head();
3120+
let offset = snapshot.point_to_offset(head);
3121+
3122+
let surrounding_code_block_range = find_surrounding_code_block(snapshot, offset)?;
3123+
let text = snapshot
3124+
.text_for_range(surrounding_code_block_range)
3125+
.collect::<String>();
3126+
3127+
(!text.is_empty()).then_some((text, true))
3128+
} else {
3129+
let anchor = context_editor.selections.newest_anchor();
3130+
let text = context_editor
3131+
.buffer()
3132+
.read(cx)
3133+
.read(cx)
3134+
.text_for_range(anchor.range())
3135+
.collect::<String>();
3136+
3137+
(!text.is_empty()).then_some((text, false))
3138+
}
3139+
}
3140+
31033141
fn insert_selection(
31043142
workspace: &mut Workspace,
31053143
_: &InsertIntoEditor,
@@ -3118,24 +3156,44 @@ impl ContextEditor {
31183156
return;
31193157
};
31203158

3121-
let context_editor = context_editor_view.read(cx).editor.read(cx);
3122-
let anchor = context_editor.selections.newest_anchor();
3123-
let text = context_editor
3124-
.buffer()
3125-
.read(cx)
3126-
.read(cx)
3127-
.text_for_range(anchor.range())
3128-
.collect::<String>();
3129-
3130-
// If nothing is selected, don't delete the current selection; instead, be a no-op.
3131-
if !text.is_empty() {
3159+
if let Some((text, _)) = Self::get_selection_or_code_block(&context_editor_view, cx) {
31323160
active_editor_view.update(cx, |editor, cx| {
31333161
editor.insert(&text, cx);
31343162
editor.focus(cx);
31353163
})
31363164
}
31373165
}
31383166

3167+
fn copy_code(workspace: &mut Workspace, _: &CopyCode, cx: &mut ViewContext<Workspace>) {
3168+
let result = maybe!({
3169+
let panel = workspace.panel::<AssistantPanel>(cx)?;
3170+
let context_editor_view = panel.read(cx).active_context_editor(cx)?;
3171+
Self::get_selection_or_code_block(&context_editor_view, cx)
3172+
});
3173+
let Some((text, is_code_block)) = result else {
3174+
return;
3175+
};
3176+
3177+
cx.write_to_clipboard(ClipboardItem::new_string(text));
3178+
3179+
struct CopyToClipboardToast;
3180+
workspace.show_toast(
3181+
Toast::new(
3182+
NotificationId::unique::<CopyToClipboardToast>(),
3183+
format!(
3184+
"{} copied to clipboard.",
3185+
if is_code_block {
3186+
"Code block"
3187+
} else {
3188+
"Selection"
3189+
}
3190+
),
3191+
)
3192+
.autohide(),
3193+
cx,
3194+
);
3195+
}
3196+
31393197
fn insert_dragged_files(
31403198
workspace: &mut Workspace,
31413199
action: &InsertDraggedFiles,
@@ -4215,6 +4273,48 @@ impl ContextEditor {
42154273
}
42164274
}
42174275

4276+
/// Returns the contents of the *outermost* fenced code block that contains the given offset.
4277+
fn find_surrounding_code_block(snapshot: &BufferSnapshot, offset: usize) -> Option<Range<usize>> {
4278+
const CODE_BLOCK_NODE: &'static str = "fenced_code_block";
4279+
const CODE_BLOCK_CONTENT: &'static str = "code_fence_content";
4280+
4281+
let layer = snapshot.syntax_layers().next()?;
4282+
4283+
let root_node = layer.node();
4284+
let mut cursor = root_node.walk();
4285+
4286+
// Go to the first child for the given offset
4287+
while cursor.goto_first_child_for_byte(offset).is_some() {
4288+
// If we're at the end of the node, go to the next one.
4289+
// Example: if you have a fenced-code-block, and you're on the start of the line
4290+
// right after the closing ```, you want to skip the fenced-code-block and
4291+
// go to the next sibling.
4292+
if cursor.node().end_byte() == offset {
4293+
cursor.goto_next_sibling();
4294+
}
4295+
4296+
if cursor.node().start_byte() > offset {
4297+
break;
4298+
}
4299+
4300+
// We found the fenced code block.
4301+
if cursor.node().kind() == CODE_BLOCK_NODE {
4302+
// Now we need to find the child node that contains the code.
4303+
cursor.goto_first_child();
4304+
loop {
4305+
if cursor.node().kind() == CODE_BLOCK_CONTENT {
4306+
return Some(cursor.node().byte_range());
4307+
}
4308+
if !cursor.goto_next_sibling() {
4309+
break;
4310+
}
4311+
}
4312+
}
4313+
}
4314+
4315+
None
4316+
}
4317+
42184318
fn render_fold_icon_button(
42194319
editor: WeakView<Editor>,
42204320
icon: IconName,
@@ -5497,3 +5597,85 @@ fn configuration_error(cx: &AppContext) -> Option<ConfigurationError> {
54975597

54985598
None
54995599
}
5600+
5601+
#[cfg(test)]
5602+
mod tests {
5603+
use super::*;
5604+
use gpui::{AppContext, Context};
5605+
use language::Buffer;
5606+
use unindent::Unindent;
5607+
5608+
#[gpui::test]
5609+
fn test_find_code_blocks(cx: &mut AppContext) {
5610+
let markdown = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
5611+
5612+
let buffer = cx.new_model(|cx| {
5613+
let text = r#"
5614+
line 0
5615+
line 1
5616+
```rust
5617+
fn main() {}
5618+
```
5619+
line 5
5620+
line 6
5621+
line 7
5622+
```go
5623+
func main() {}
5624+
```
5625+
line 11
5626+
```
5627+
this is plain text code block
5628+
```
5629+
5630+
```go
5631+
func another() {}
5632+
```
5633+
line 19
5634+
"#
5635+
.unindent();
5636+
let mut buffer = Buffer::local(text, cx);
5637+
buffer.set_language(Some(markdown.clone()), cx);
5638+
buffer
5639+
});
5640+
let snapshot = buffer.read(cx).snapshot();
5641+
5642+
let code_blocks = vec![
5643+
Point::new(3, 0)..Point::new(4, 0),
5644+
Point::new(9, 0)..Point::new(10, 0),
5645+
Point::new(13, 0)..Point::new(14, 0),
5646+
Point::new(17, 0)..Point::new(18, 0),
5647+
]
5648+
.into_iter()
5649+
.map(|range| snapshot.point_to_offset(range.start)..snapshot.point_to_offset(range.end))
5650+
.collect::<Vec<_>>();
5651+
5652+
let expected_results = vec![
5653+
(0, None),
5654+
(1, None),
5655+
(2, Some(code_blocks[0].clone())),
5656+
(3, Some(code_blocks[0].clone())),
5657+
(4, Some(code_blocks[0].clone())),
5658+
(5, None),
5659+
(6, None),
5660+
(7, None),
5661+
(8, Some(code_blocks[1].clone())),
5662+
(9, Some(code_blocks[1].clone())),
5663+
(10, Some(code_blocks[1].clone())),
5664+
(11, None),
5665+
(12, Some(code_blocks[2].clone())),
5666+
(13, Some(code_blocks[2].clone())),
5667+
(14, Some(code_blocks[2].clone())),
5668+
(15, None),
5669+
(16, Some(code_blocks[3].clone())),
5670+
(17, Some(code_blocks[3].clone())),
5671+
(18, Some(code_blocks[3].clone())),
5672+
(19, None),
5673+
];
5674+
5675+
for (row, expected) in expected_results {
5676+
let offset = snapshot.point_to_offset(Point::new(row, 0));
5677+
let range = find_surrounding_code_block(&snapshot, offset);
5678+
assert_eq!(range, expected, "unexpected result on row {:?}", row);
5679+
}
5680+
}
5681+
}

0 commit comments

Comments
 (0)