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:
parent
ca4980df02
commit
1723713dc2
6 changed files with 207 additions and 18 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -402,6 +402,7 @@ dependencies = [
|
||||||
"indoc",
|
"indoc",
|
||||||
"language",
|
"language",
|
||||||
"language_model",
|
"language_model",
|
||||||
|
"languages",
|
||||||
"log",
|
"log",
|
||||||
"markdown",
|
"markdown",
|
||||||
"menu",
|
"menu",
|
||||||
|
@ -436,6 +437,7 @@ dependencies = [
|
||||||
"text",
|
"text",
|
||||||
"theme",
|
"theme",
|
||||||
"toml 0.8.19",
|
"toml 0.8.19",
|
||||||
|
"tree-sitter-md",
|
||||||
"ui",
|
"ui",
|
||||||
"unindent",
|
"unindent",
|
||||||
"util",
|
"util",
|
||||||
|
|
|
@ -166,6 +166,7 @@
|
||||||
{
|
{
|
||||||
"context": "AssistantPanel",
|
"context": "AssistantPanel",
|
||||||
"bindings": {
|
"bindings": {
|
||||||
|
"ctrl-k c": "assistant::CopyCode",
|
||||||
"ctrl-g": "search::SelectNextMatch",
|
"ctrl-g": "search::SelectNextMatch",
|
||||||
"ctrl-shift-g": "search::SelectPrevMatch",
|
"ctrl-shift-g": "search::SelectPrevMatch",
|
||||||
"alt-m": "assistant::ToggleModelSelector",
|
"alt-m": "assistant::ToggleModelSelector",
|
||||||
|
|
|
@ -188,6 +188,7 @@
|
||||||
{
|
{
|
||||||
"context": "AssistantPanel",
|
"context": "AssistantPanel",
|
||||||
"bindings": {
|
"bindings": {
|
||||||
|
"cmd-k c": "assistant::CopyCode",
|
||||||
"cmd-g": "search::SelectNextMatch",
|
"cmd-g": "search::SelectNextMatch",
|
||||||
"cmd-shift-g": "search::SelectPrevMatch",
|
"cmd-shift-g": "search::SelectPrevMatch",
|
||||||
"alt-m": "assistant::ToggleModelSelector",
|
"alt-m": "assistant::ToggleModelSelector",
|
||||||
|
|
|
@ -94,9 +94,11 @@ editor = { workspace = true, features = ["test-support"] }
|
||||||
env_logger.workspace = true
|
env_logger.workspace = true
|
||||||
language = { workspace = true, features = ["test-support"] }
|
language = { workspace = true, features = ["test-support"] }
|
||||||
language_model = { workspace = true, features = ["test-support"] }
|
language_model = { workspace = true, features = ["test-support"] }
|
||||||
|
languages = { workspace = true, features = ["test-support"] }
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
project = { workspace = true, features = ["test-support"] }
|
project = { workspace = true, features = ["test-support"] }
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
serde_json_lenient.workspace = true
|
serde_json_lenient.workspace = true
|
||||||
text = { workspace = true, features = ["test-support"] }
|
text = { workspace = true, features = ["test-support"] }
|
||||||
|
tree-sitter-md.workspace = true
|
||||||
unindent.workspace = true
|
unindent.workspace = true
|
||||||
|
|
|
@ -58,6 +58,7 @@ actions!(
|
||||||
[
|
[
|
||||||
Assist,
|
Assist,
|
||||||
Split,
|
Split,
|
||||||
|
CopyCode,
|
||||||
CycleMessageRole,
|
CycleMessageRole,
|
||||||
QuoteSelection,
|
QuoteSelection,
|
||||||
InsertIntoEditor,
|
InsertIntoEditor,
|
||||||
|
|
|
@ -12,11 +12,11 @@ use crate::{
|
||||||
slash_command_picker,
|
slash_command_picker,
|
||||||
terminal_inline_assistant::TerminalInlineAssistant,
|
terminal_inline_assistant::TerminalInlineAssistant,
|
||||||
Assist, CacheStatus, ConfirmCommand, Content, Context, ContextEvent, ContextId, ContextStore,
|
Assist, CacheStatus, ConfirmCommand, Content, Context, ContextEvent, ContextId, ContextStore,
|
||||||
ContextStoreEvent, CycleMessageRole, DeployHistory, DeployPromptLibrary, InlineAssistId,
|
ContextStoreEvent, CopyCode, CycleMessageRole, DeployHistory, DeployPromptLibrary,
|
||||||
InlineAssistant, InsertDraggedFiles, InsertIntoEditor, Message, MessageId, MessageMetadata,
|
InlineAssistId, InlineAssistant, InsertDraggedFiles, InsertIntoEditor, Message, MessageId,
|
||||||
MessageStatus, ModelPickerDelegate, ModelSelector, NewContext, PendingSlashCommand,
|
MessageMetadata, MessageStatus, ModelPickerDelegate, ModelSelector, NewContext,
|
||||||
PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata, SavedContextMetadata, Split,
|
PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata,
|
||||||
ToggleFocus, ToggleModelSelector, WorkflowStepResolution,
|
SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector, WorkflowStepResolution,
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection};
|
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection};
|
||||||
|
@ -45,7 +45,8 @@ use gpui::{
|
||||||
};
|
};
|
||||||
use indexed_docs::IndexedDocsStore;
|
use indexed_docs::IndexedDocsStore;
|
||||||
use language::{
|
use language::{
|
||||||
language_settings::SoftWrap, Capability, LanguageRegistry, LspAdapterDelegate, Point, ToOffset,
|
language_settings::SoftWrap, BufferSnapshot, Capability, LanguageRegistry, LspAdapterDelegate,
|
||||||
|
ToOffset,
|
||||||
};
|
};
|
||||||
use language_model::{
|
use language_model::{
|
||||||
provider::cloud::PROVIDER_ID, LanguageModelProvider, LanguageModelProviderId,
|
provider::cloud::PROVIDER_ID, LanguageModelProvider, LanguageModelProviderId,
|
||||||
|
@ -56,6 +57,7 @@ use multi_buffer::MultiBufferRow;
|
||||||
use picker::{Picker, PickerDelegate};
|
use picker::{Picker, PickerDelegate};
|
||||||
use project::lsp_store::LocalLspAdapterDelegate;
|
use project::lsp_store::LocalLspAdapterDelegate;
|
||||||
use project::{Project, Worktree};
|
use project::{Project, Worktree};
|
||||||
|
use rope::Point;
|
||||||
use search::{buffer_search::DivRegistrar, BufferSearchBar};
|
use search::{buffer_search::DivRegistrar, BufferSearchBar};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use settings::{update_settings_file, Settings};
|
use settings::{update_settings_file, Settings};
|
||||||
|
@ -81,9 +83,10 @@ use util::{maybe, ResultExt};
|
||||||
use workspace::{
|
use workspace::{
|
||||||
dock::{DockPosition, Panel, PanelEvent},
|
dock::{DockPosition, Panel, PanelEvent},
|
||||||
item::{self, FollowableItem, Item, ItemHandle},
|
item::{self, FollowableItem, Item, ItemHandle},
|
||||||
|
notifications::NotificationId,
|
||||||
pane::{self, SaveIntent},
|
pane::{self, SaveIntent},
|
||||||
searchable::{SearchEvent, SearchableItem},
|
searchable::{SearchEvent, SearchableItem},
|
||||||
DraggedSelection, Pane, Save, ShowConfiguration, ToggleZoom, ToolbarItemEvent,
|
DraggedSelection, Pane, Save, ShowConfiguration, Toast, ToggleZoom, ToolbarItemEvent,
|
||||||
ToolbarItemLocation, ToolbarItemView, Workspace,
|
ToolbarItemLocation, ToolbarItemView, Workspace,
|
||||||
};
|
};
|
||||||
use workspace::{searchable::SearchableItemHandle, DraggedTab};
|
use workspace::{searchable::SearchableItemHandle, DraggedTab};
|
||||||
|
@ -105,6 +108,7 @@ pub fn init(cx: &mut AppContext) {
|
||||||
.register_action(AssistantPanel::inline_assist)
|
.register_action(AssistantPanel::inline_assist)
|
||||||
.register_action(ContextEditor::quote_selection)
|
.register_action(ContextEditor::quote_selection)
|
||||||
.register_action(ContextEditor::insert_selection)
|
.register_action(ContextEditor::insert_selection)
|
||||||
|
.register_action(ContextEditor::copy_code)
|
||||||
.register_action(ContextEditor::insert_dragged_files)
|
.register_action(ContextEditor::insert_dragged_files)
|
||||||
.register_action(AssistantPanel::show_configuration)
|
.register_action(AssistantPanel::show_configuration)
|
||||||
.register_action(AssistantPanel::create_new_context);
|
.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(
|
fn insert_selection(
|
||||||
workspace: &mut Workspace,
|
workspace: &mut Workspace,
|
||||||
_: &InsertIntoEditor,
|
_: &InsertIntoEditor,
|
||||||
|
@ -3118,17 +3156,7 @@ impl ContextEditor {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let context_editor = context_editor_view.read(cx).editor.read(cx);
|
if let Some((text, _)) = Self::get_selection_or_code_block(&context_editor_view, 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() {
|
|
||||||
active_editor_view.update(cx, |editor, cx| {
|
active_editor_view.update(cx, |editor, cx| {
|
||||||
editor.insert(&text, cx);
|
editor.insert(&text, cx);
|
||||||
editor.focus(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(
|
fn insert_dragged_files(
|
||||||
workspace: &mut Workspace,
|
workspace: &mut Workspace,
|
||||||
action: &InsertDraggedFiles,
|
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(
|
fn render_fold_icon_button(
|
||||||
editor: WeakView<Editor>,
|
editor: WeakView<Editor>,
|
||||||
icon: IconName,
|
icon: IconName,
|
||||||
|
@ -5497,3 +5597,85 @@ fn configuration_error(cx: &AppContext) -> Option<ConfigurationError> {
|
||||||
|
|
||||||
None
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue