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>
This commit is contained in:
thataboy 2024-09-19 04:43:49 -07:00 committed by GitHub
parent ca4980df02
commit 1723713dc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 207 additions and 18 deletions

2
Cargo.lock generated
View file

@ -402,6 +402,7 @@ dependencies = [
"indoc",
"language",
"language_model",
"languages",
"log",
"markdown",
"menu",
@ -436,6 +437,7 @@ dependencies = [
"text",
"theme",
"toml 0.8.19",
"tree-sitter-md",
"ui",
"unindent",
"util",

View file

@ -166,6 +166,7 @@
{
"context": "AssistantPanel",
"bindings": {
"ctrl-k c": "assistant::CopyCode",
"ctrl-g": "search::SelectNextMatch",
"ctrl-shift-g": "search::SelectPrevMatch",
"alt-m": "assistant::ToggleModelSelector",

View file

@ -188,6 +188,7 @@
{
"context": "AssistantPanel",
"bindings": {
"cmd-k c": "assistant::CopyCode",
"cmd-g": "search::SelectNextMatch",
"cmd-shift-g": "search::SelectPrevMatch",
"alt-m": "assistant::ToggleModelSelector",

View file

@ -94,9 +94,11 @@ editor = { workspace = true, features = ["test-support"] }
env_logger.workspace = true
language = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, features = ["test-support"] }
languages = { workspace = true, features = ["test-support"] }
log.workspace = true
project = { workspace = true, features = ["test-support"] }
rand.workspace = true
serde_json_lenient.workspace = true
text = { workspace = true, features = ["test-support"] }
tree-sitter-md.workspace = true
unindent.workspace = true

View file

@ -58,6 +58,7 @@ actions!(
[
Assist,
Split,
CopyCode,
CycleMessageRole,
QuoteSelection,
InsertIntoEditor,

View file

@ -12,11 +12,11 @@ use crate::{
slash_command_picker,
terminal_inline_assistant::TerminalInlineAssistant,
Assist, CacheStatus, ConfirmCommand, Content, Context, ContextEvent, ContextId, ContextStore,
ContextStoreEvent, CycleMessageRole, DeployHistory, DeployPromptLibrary, InlineAssistId,
InlineAssistant, InsertDraggedFiles, InsertIntoEditor, Message, MessageId, MessageMetadata,
MessageStatus, ModelPickerDelegate, ModelSelector, NewContext, PendingSlashCommand,
PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata, SavedContextMetadata, Split,
ToggleFocus, ToggleModelSelector, WorkflowStepResolution,
ContextStoreEvent, CopyCode, CycleMessageRole, DeployHistory, DeployPromptLibrary,
InlineAssistId, InlineAssistant, InsertDraggedFiles, InsertIntoEditor, Message, MessageId,
MessageMetadata, MessageStatus, ModelPickerDelegate, ModelSelector, NewContext,
PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata,
SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector, WorkflowStepResolution,
};
use anyhow::{anyhow, Result};
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection};
@ -45,7 +45,8 @@ use gpui::{
};
use indexed_docs::IndexedDocsStore;
use language::{
language_settings::SoftWrap, Capability, LanguageRegistry, LspAdapterDelegate, Point, ToOffset,
language_settings::SoftWrap, BufferSnapshot, Capability, LanguageRegistry, LspAdapterDelegate,
ToOffset,
};
use language_model::{
provider::cloud::PROVIDER_ID, LanguageModelProvider, LanguageModelProviderId,
@ -56,6 +57,7 @@ use multi_buffer::MultiBufferRow;
use picker::{Picker, PickerDelegate};
use project::lsp_store::LocalLspAdapterDelegate;
use project::{Project, Worktree};
use rope::Point;
use search::{buffer_search::DivRegistrar, BufferSearchBar};
use serde::{Deserialize, Serialize};
use settings::{update_settings_file, Settings};
@ -81,9 +83,10 @@ use util::{maybe, ResultExt};
use workspace::{
dock::{DockPosition, Panel, PanelEvent},
item::{self, FollowableItem, Item, ItemHandle},
notifications::NotificationId,
pane::{self, SaveIntent},
searchable::{SearchEvent, SearchableItem},
DraggedSelection, Pane, Save, ShowConfiguration, ToggleZoom, ToolbarItemEvent,
DraggedSelection, Pane, Save, ShowConfiguration, Toast, ToggleZoom, ToolbarItemEvent,
ToolbarItemLocation, ToolbarItemView, Workspace,
};
use workspace::{searchable::SearchableItemHandle, DraggedTab};
@ -105,6 +108,7 @@ pub fn init(cx: &mut AppContext) {
.register_action(AssistantPanel::inline_assist)
.register_action(ContextEditor::quote_selection)
.register_action(ContextEditor::insert_selection)
.register_action(ContextEditor::copy_code)
.register_action(ContextEditor::insert_dragged_files)
.register_action(AssistantPanel::show_configuration)
.register_action(AssistantPanel::create_new_context);
@ -3100,6 +3104,40 @@ impl ContextEditor {
});
}
/// Returns either the selected text, or the content of the Markdown code
/// block surrounding the cursor.
fn get_selection_or_code_block(
context_editor_view: &View<ContextEditor>,
cx: &mut ViewContext<Workspace>,
) -> Option<(String, bool)> {
let context_editor = context_editor_view.read(cx).editor.read(cx);
if context_editor.selections.newest::<Point>(cx).is_empty() {
let snapshot = context_editor.buffer().read(cx).snapshot(cx);
let (_, _, snapshot) = snapshot.as_singleton()?;
let head = context_editor.selections.newest::<Point>(cx).head();
let offset = snapshot.point_to_offset(head);
let surrounding_code_block_range = find_surrounding_code_block(snapshot, offset)?;
let text = snapshot
.text_for_range(surrounding_code_block_range)
.collect::<String>();
(!text.is_empty()).then_some((text, true))
} else {
let anchor = context_editor.selections.newest_anchor();
let text = context_editor
.buffer()
.read(cx)
.read(cx)
.text_for_range(anchor.range())
.collect::<String>();
(!text.is_empty()).then_some((text, false))
}
}
fn insert_selection(
workspace: &mut Workspace,
_: &InsertIntoEditor,
@ -3118,17 +3156,7 @@ impl ContextEditor {
return;
};
let context_editor = context_editor_view.read(cx).editor.read(cx);
let anchor = context_editor.selections.newest_anchor();
let text = context_editor
.buffer()
.read(cx)
.read(cx)
.text_for_range(anchor.range())
.collect::<String>();
// If nothing is selected, don't delete the current selection; instead, be a no-op.
if !text.is_empty() {
if let Some((text, _)) = Self::get_selection_or_code_block(&context_editor_view, cx) {
active_editor_view.update(cx, |editor, cx| {
editor.insert(&text, cx);
editor.focus(cx);
@ -3136,6 +3164,36 @@ impl ContextEditor {
}
}
fn copy_code(workspace: &mut Workspace, _: &CopyCode, cx: &mut ViewContext<Workspace>) {
let result = maybe!({
let panel = workspace.panel::<AssistantPanel>(cx)?;
let context_editor_view = panel.read(cx).active_context_editor(cx)?;
Self::get_selection_or_code_block(&context_editor_view, cx)
});
let Some((text, is_code_block)) = result else {
return;
};
cx.write_to_clipboard(ClipboardItem::new_string(text));
struct CopyToClipboardToast;
workspace.show_toast(
Toast::new(
NotificationId::unique::<CopyToClipboardToast>(),
format!(
"{} copied to clipboard.",
if is_code_block {
"Code block"
} else {
"Selection"
}
),
)
.autohide(),
cx,
);
}
fn insert_dragged_files(
workspace: &mut Workspace,
action: &InsertDraggedFiles,
@ -4215,6 +4273,48 @@ impl ContextEditor {
}
}
/// Returns the contents of the *outermost* fenced code block that contains the given offset.
fn find_surrounding_code_block(snapshot: &BufferSnapshot, offset: usize) -> Option<Range<usize>> {
const CODE_BLOCK_NODE: &'static str = "fenced_code_block";
const CODE_BLOCK_CONTENT: &'static str = "code_fence_content";
let layer = snapshot.syntax_layers().next()?;
let root_node = layer.node();
let mut cursor = root_node.walk();
// Go to the first child for the given offset
while cursor.goto_first_child_for_byte(offset).is_some() {
// If we're at the end of the node, go to the next one.
// Example: if you have a fenced-code-block, and you're on the start of the line
// right after the closing ```, you want to skip the fenced-code-block and
// go to the next sibling.
if cursor.node().end_byte() == offset {
cursor.goto_next_sibling();
}
if cursor.node().start_byte() > offset {
break;
}
// We found the fenced code block.
if cursor.node().kind() == CODE_BLOCK_NODE {
// Now we need to find the child node that contains the code.
cursor.goto_first_child();
loop {
if cursor.node().kind() == CODE_BLOCK_CONTENT {
return Some(cursor.node().byte_range());
}
if !cursor.goto_next_sibling() {
break;
}
}
}
}
None
}
fn render_fold_icon_button(
editor: WeakView<Editor>,
icon: IconName,
@ -5497,3 +5597,85 @@ fn configuration_error(cx: &AppContext) -> Option<ConfigurationError> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{AppContext, Context};
use language::Buffer;
use unindent::Unindent;
#[gpui::test]
fn test_find_code_blocks(cx: &mut AppContext) {
let markdown = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
let buffer = cx.new_model(|cx| {
let text = r#"
line 0
line 1
```rust
fn main() {}
```
line 5
line 6
line 7
```go
func main() {}
```
line 11
```
this is plain text code block
```
```go
func another() {}
```
line 19
"#
.unindent();
let mut buffer = Buffer::local(text, cx);
buffer.set_language(Some(markdown.clone()), cx);
buffer
});
let snapshot = buffer.read(cx).snapshot();
let code_blocks = vec![
Point::new(3, 0)..Point::new(4, 0),
Point::new(9, 0)..Point::new(10, 0),
Point::new(13, 0)..Point::new(14, 0),
Point::new(17, 0)..Point::new(18, 0),
]
.into_iter()
.map(|range| snapshot.point_to_offset(range.start)..snapshot.point_to_offset(range.end))
.collect::<Vec<_>>();
let expected_results = vec![
(0, None),
(1, None),
(2, Some(code_blocks[0].clone())),
(3, Some(code_blocks[0].clone())),
(4, Some(code_blocks[0].clone())),
(5, None),
(6, None),
(7, None),
(8, Some(code_blocks[1].clone())),
(9, Some(code_blocks[1].clone())),
(10, Some(code_blocks[1].clone())),
(11, None),
(12, Some(code_blocks[2].clone())),
(13, Some(code_blocks[2].clone())),
(14, Some(code_blocks[2].clone())),
(15, None),
(16, Some(code_blocks[3].clone())),
(17, Some(code_blocks[3].clone())),
(18, Some(code_blocks[3].clone())),
(19, None),
];
for (row, expected) in expected_results {
let offset = snapshot.point_to_offset(Point::new(row, 0));
let range = find_surrounding_code_block(&snapshot, offset);
assert_eq!(range, expected, "unexpected result on row {:?}", row);
}
}
}