Introduce an Assistant Panel (#2584)
This pull request introduces a new assistant panel to Zed that lets users interact with OpenAI using their API key:  After setting the key up, it will be saved to the keychain and automatically loaded the next time the assistant panel is opened. The key can be reset using `assistant: reset key`.  From there, users can type messages in a multi-buffer and hit `cmd-enter` (`assistant: assist`) to stream assistant responses using the OpenAI API. Responses can be canceled by hitting `escape`.  Users can quote a selection from the active editor by hitting `cmd->` (`assistant: quote selection`), which will embed the selected piece of text in a Markdown fenced code block. Conversations with the assistant are ephemeral at the moment, but can be easily copy/pasted:  Release Notes: - Added a new assistant panel feature that enables interacting with OpenAI using an API key. This replaces the previous experimental `ai: assist` command that would work on any buffer. The experience is similar to the one offered by ChatGPT with the added ability to edit, delete or enhance previous messages. When hitting `cmd-enter`, the assistant will start streaming responses from OpenAI. A response stream can be canceled using `escape`. Moreover, the active editor's selection can be quoted in the assistant panel using `cmd->`, which will automatically embed the selected piece of text in a Markdown fenced code block.
This commit is contained in:
Generated
+52
-1
@@ -100,15 +100,24 @@ name = "ai"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"collections",
|
||||
"editor",
|
||||
"fs",
|
||||
"futures 0.3.28",
|
||||
"gpui",
|
||||
"isahc",
|
||||
"rust-embed",
|
||||
"language",
|
||||
"menu",
|
||||
"schemars",
|
||||
"search",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"theme",
|
||||
"tiktoken-rs",
|
||||
"util",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -718,6 +727,21 @@ dependencies = [
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -843,6 +867,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d4260bcc2e8fc9df1eac4919a720effeb63a3f0952f5bf4944adfa18897f09"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -2177,6 +2203,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "1.9.0"
|
||||
@@ -6921,6 +6957,21 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken-rs"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ba161c549e2c0686f35f5d920e63fad5cafba2c28ad2caceaf07e5d9fa6e8c4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.21.0",
|
||||
"bstr",
|
||||
"fancy-regex",
|
||||
"lazy_static",
|
||||
"parking_lot 0.12.1",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.1.45"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6667 0.400196H1.33346C0.819658 0.400196 0.399658 0.820196 0.399658 1.3326V10.6658C0.399658 11.181 0.816998 11.5982 1.33206 11.5982C1.58966 11.5982 1.82206 11.4932 1.99146 11.3238L4.51706 8.79684H10.6639C11.1763 8.79684 11.5963 8.37544 11.5963 7.86304V1.3298C11.5963 0.815996 11.1749 0.395996 10.6625 0.395996L10.6667 0.400196ZM2.2667 2.2664H6.00008V3.1988H2.26628V2.265L2.2667 2.2664ZM7.8667 6.93316H2.2667V5.99936H7.8667V6.93176V6.93316ZM9.7329 5.06556H2.26488V4.13176H9.73164V5.06416L9.7329 5.06556Z" fill="#282C34"/>
|
||||
<svg width="12" height="11" viewBox="0 0 12 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.01077 0.000234794C2.69085 0.000234794 0.000639074 2.18612 0.000639074 4.88385C0.000639074 6.0491 0.501914 7.11387 1.33823 7.95254C1.04475 9.13517 0.0640321 10.1894 0.0522927 10.2011C-0.00053487 10.2539 -0.0153266 10.3356 0.0170743 10.4061C0.0464229 10.4763 0.111459 10.5185 0.187766 10.5185C1.74324 10.5185 2.89019 9.77286 3.4889 9.31197C4.25431 9.60052 5.10894 9.76722 6.01053 9.76722C9.33045 9.76722 12 7.58063 12 4.88361C12 2.18659 9.33045 0 6.01053 0L6.01077 0.000234794Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 636 B After Width: | Height: | Size: 609 B |
@@ -185,13 +185,8 @@
|
||||
],
|
||||
"alt-\\": "copilot::Suggest",
|
||||
"alt-]": "copilot::NextSuggestion",
|
||||
"alt-[": "copilot::PreviousSuggestion"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Editor && extension == zmd",
|
||||
"bindings": {
|
||||
"cmd-enter": "ai::Assist"
|
||||
"alt-[": "copilot::PreviousSuggestion",
|
||||
"cmd->": "assistant::QuoteSelection"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -201,6 +196,13 @@
|
||||
"cmd-alt-enter": "editor::NewlineBelow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AssistantEditor > Editor",
|
||||
"bindings": {
|
||||
"cmd-enter": "assistant::Assist",
|
||||
"cmd->": "assistant::QuoteSelection"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "BufferSearchBar",
|
||||
"bindings": {
|
||||
|
||||
@@ -81,6 +81,14 @@
|
||||
// Default width of the project panel.
|
||||
"default_width": 240
|
||||
},
|
||||
"assistant": {
|
||||
// Where to dock the assistant. Can be 'left', 'right' or 'bottom'.
|
||||
"dock": "right",
|
||||
// Default width when the assistant is docked to the left or right.
|
||||
"default_width": 450,
|
||||
// Default height when the assistant is docked to the bottom.
|
||||
"default_height": 320
|
||||
},
|
||||
// Whether the screen sharing icon is shown in the os status bar.
|
||||
"show_call_status_icon": true,
|
||||
// Whether to use language servers to provide code intelligence.
|
||||
|
||||
+12
-3
@@ -11,15 +11,24 @@ doctest = false
|
||||
[dependencies]
|
||||
collections = { path = "../collections"}
|
||||
editor = { path = "../editor" }
|
||||
fs = { path = "../fs" }
|
||||
gpui = { path = "../gpui" }
|
||||
language = { path = "../language" }
|
||||
menu = { path = "../menu" }
|
||||
search = { path = "../search" }
|
||||
settings = { path = "../settings" }
|
||||
theme = { path = "../theme" }
|
||||
util = { path = "../util" }
|
||||
workspace = { path = "../workspace" }
|
||||
|
||||
rust-embed.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono = "0.4"
|
||||
futures.workspace = true
|
||||
isahc.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tiktoken-rs = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
editor = { path = "../editor", features = ["test-support"] }
|
||||
|
||||
+28
-250
@@ -1,29 +1,10 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use collections::HashMap;
|
||||
use editor::Editor;
|
||||
use futures::AsyncBufReadExt;
|
||||
use futures::{io::BufReader, AsyncReadExt, Stream, StreamExt};
|
||||
use gpui::executor::Background;
|
||||
use gpui::{actions, AppContext, Task, ViewContext};
|
||||
use isahc::prelude::*;
|
||||
use isahc::{http::StatusCode, Request};
|
||||
pub mod assistant;
|
||||
mod assistant_settings;
|
||||
|
||||
pub use assistant::AssistantPanel;
|
||||
use gpui::AppContext;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cell::RefCell;
|
||||
use std::fs;
|
||||
use std::rc::Rc;
|
||||
use std::{io, sync::Arc};
|
||||
use util::channel::{ReleaseChannel, RELEASE_CHANNEL};
|
||||
use util::{ResultExt, TryFutureExt};
|
||||
|
||||
use rust_embed::RustEmbed;
|
||||
use std::str;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../assets/contexts"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct ContextAssets;
|
||||
|
||||
actions!(ai, [Assist]);
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
// Data types for chat completion requests
|
||||
#[derive(Serialize)]
|
||||
@@ -45,7 +26,7 @@ struct ResponseMessage {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum Role {
|
||||
User,
|
||||
@@ -53,6 +34,26 @@ enum Role {
|
||||
System,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn cycle(&mut self) {
|
||||
*self = match self {
|
||||
Role::User => Role::Assistant,
|
||||
Role::Assistant => Role::System,
|
||||
Role::System => Role::User,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Role {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Role::User => write!(f, "User"),
|
||||
Role::Assistant => write!(f, "Assistant"),
|
||||
Role::System => write!(f, "System"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct OpenAIResponseStreamEvent {
|
||||
pub id: Option<String>,
|
||||
@@ -93,228 +94,5 @@ struct OpenAIChoice {
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
if *RELEASE_CHANNEL == ReleaseChannel::Stable {
|
||||
return;
|
||||
}
|
||||
|
||||
let assistant = Rc::new(Assistant::default());
|
||||
cx.add_action({
|
||||
let assistant = assistant.clone();
|
||||
move |editor: &mut Editor, _: &Assist, cx: &mut ViewContext<Editor>| {
|
||||
assistant.assist(editor, cx).log_err();
|
||||
}
|
||||
});
|
||||
cx.capture_action({
|
||||
let assistant = assistant.clone();
|
||||
move |_: &mut Editor, _: &editor::Cancel, cx: &mut ViewContext<Editor>| {
|
||||
if !assistant.cancel_last_assist(cx.view_id()) {
|
||||
cx.propagate_action();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type CompletionId = usize;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Assistant(RefCell<AssistantState>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct AssistantState {
|
||||
assist_stacks: HashMap<usize, Vec<(CompletionId, Task<Option<()>>)>>,
|
||||
next_completion_id: CompletionId,
|
||||
}
|
||||
|
||||
impl Assistant {
|
||||
fn assist(self: &Rc<Self>, editor: &mut Editor, cx: &mut ViewContext<Editor>) -> Result<()> {
|
||||
let api_key = std::env::var("OPENAI_API_KEY")?;
|
||||
|
||||
let selections = editor.selections.all(cx);
|
||||
let (user_message, insertion_site) = editor.buffer().update(cx, |buffer, cx| {
|
||||
// Insert markers around selected text as described in the system prompt above.
|
||||
let snapshot = buffer.snapshot(cx);
|
||||
let mut user_message = String::new();
|
||||
let mut user_message_suffix = String::new();
|
||||
let mut buffer_offset = 0;
|
||||
for selection in selections {
|
||||
if !selection.is_empty() {
|
||||
if user_message_suffix.is_empty() {
|
||||
user_message_suffix.push_str("\n\n");
|
||||
}
|
||||
user_message_suffix.push_str("[Selected excerpt from above]\n");
|
||||
user_message_suffix
|
||||
.extend(snapshot.text_for_range(selection.start..selection.end));
|
||||
user_message_suffix.push_str("\n\n");
|
||||
}
|
||||
|
||||
user_message.extend(snapshot.text_for_range(buffer_offset..selection.start));
|
||||
user_message.push_str("[SELECTION_START]");
|
||||
user_message.extend(snapshot.text_for_range(selection.start..selection.end));
|
||||
buffer_offset = selection.end;
|
||||
user_message.push_str("[SELECTION_END]");
|
||||
}
|
||||
if buffer_offset < snapshot.len() {
|
||||
user_message.extend(snapshot.text_for_range(buffer_offset..snapshot.len()));
|
||||
}
|
||||
user_message.push_str(&user_message_suffix);
|
||||
|
||||
// Ensure the document ends with 4 trailing newlines.
|
||||
let trailing_newline_count = snapshot
|
||||
.reversed_chars_at(snapshot.len())
|
||||
.take_while(|c| *c == '\n')
|
||||
.take(4);
|
||||
let buffer_suffix = "\n".repeat(4 - trailing_newline_count.count());
|
||||
buffer.edit([(snapshot.len()..snapshot.len(), buffer_suffix)], None, cx);
|
||||
|
||||
let snapshot = buffer.snapshot(cx); // Take a new snapshot after editing.
|
||||
let insertion_site = snapshot.anchor_after(snapshot.len() - 2);
|
||||
|
||||
(user_message, insertion_site)
|
||||
});
|
||||
|
||||
let this = self.clone();
|
||||
let buffer = editor.buffer().clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
let editor_id = cx.view_id();
|
||||
let assist_id = util::post_inc(&mut self.0.borrow_mut().next_completion_id);
|
||||
let assist_task = cx.spawn(|_, mut cx| {
|
||||
async move {
|
||||
// TODO: We should have a get_string method on assets. This is repateated elsewhere.
|
||||
let content = ContextAssets::get("system.zmd").unwrap();
|
||||
let mut system_message = std::str::from_utf8(content.data.as_ref())
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
if let Ok(custom_system_message_path) =
|
||||
std::env::var("ZED_ASSISTANT_SYSTEM_PROMPT_PATH")
|
||||
{
|
||||
system_message.push_str(
|
||||
"\n\nAlso consider the following user-defined system prompt:\n\n",
|
||||
);
|
||||
// TODO: Replace this with our file system trait object.
|
||||
system_message.push_str(
|
||||
&cx.background()
|
||||
.spawn(async move { fs::read_to_string(custom_system_message_path) })
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
let stream = stream_completion(
|
||||
api_key,
|
||||
executor,
|
||||
OpenAIRequest {
|
||||
model: "gpt-4".to_string(),
|
||||
messages: vec![
|
||||
RequestMessage {
|
||||
role: Role::System,
|
||||
content: system_message.to_string(),
|
||||
},
|
||||
RequestMessage {
|
||||
role: Role::User,
|
||||
content: user_message,
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
},
|
||||
);
|
||||
|
||||
let mut messages = stream.await?;
|
||||
while let Some(message) = messages.next().await {
|
||||
let mut message = message?;
|
||||
if let Some(choice) = message.choices.pop() {
|
||||
buffer.update(&mut cx, |buffer, cx| {
|
||||
let text: Arc<str> = choice.delta.content?.into();
|
||||
buffer.edit([(insertion_site.clone()..insertion_site, text)], None, cx);
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.0
|
||||
.borrow_mut()
|
||||
.assist_stacks
|
||||
.get_mut(&editor_id)
|
||||
.unwrap()
|
||||
.retain(|(id, _)| *id != assist_id);
|
||||
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.log_err()
|
||||
});
|
||||
|
||||
self.0
|
||||
.borrow_mut()
|
||||
.assist_stacks
|
||||
.entry(cx.view_id())
|
||||
.or_default()
|
||||
.push((assist_id, assist_task));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancel_last_assist(self: &Rc<Self>, editor_id: usize) -> bool {
|
||||
self.0
|
||||
.borrow_mut()
|
||||
.assist_stacks
|
||||
.get_mut(&editor_id)
|
||||
.and_then(|assists| assists.pop())
|
||||
.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_completion(
|
||||
api_key: String,
|
||||
executor: Arc<Background>,
|
||||
mut request: OpenAIRequest,
|
||||
) -> Result<impl Stream<Item = Result<OpenAIResponseStreamEvent>>> {
|
||||
request.stream = true;
|
||||
|
||||
let (tx, rx) = futures::channel::mpsc::unbounded::<Result<OpenAIResponseStreamEvent>>();
|
||||
|
||||
let json_data = serde_json::to_string(&request)?;
|
||||
let mut response = Request::post("https://api.openai.com/v1/chat/completions")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.body(json_data)?
|
||||
.send_async()
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::OK {
|
||||
executor
|
||||
.spawn(async move {
|
||||
let mut lines = BufReader::new(response.body_mut()).lines();
|
||||
|
||||
fn parse_line(
|
||||
line: Result<String, io::Error>,
|
||||
) -> Result<Option<OpenAIResponseStreamEvent>> {
|
||||
if let Some(data) = line?.strip_prefix("data: ") {
|
||||
let event = serde_json::from_str(&data)?;
|
||||
Ok(Some(event))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(line) = lines.next().await {
|
||||
if let Some(event) = parse_line(line).transpose() {
|
||||
tx.unbounded_send(event).log_err();
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(rx)
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
response.body_mut().read_to_string(&mut body).await?;
|
||||
|
||||
Err(anyhow!(
|
||||
"Failed to connect to OpenAI API: {} {}",
|
||||
response.status(),
|
||||
body,
|
||||
))
|
||||
}
|
||||
assistant::init(cx);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
use anyhow;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::Setting;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AssistantDockPosition {
|
||||
Left,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct AssistantSettings {
|
||||
pub dock: AssistantDockPosition,
|
||||
pub default_width: f32,
|
||||
pub default_height: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
pub struct AssistantSettingsContent {
|
||||
pub dock: Option<AssistantDockPosition>,
|
||||
pub default_width: Option<f32>,
|
||||
pub default_height: Option<f32>,
|
||||
}
|
||||
|
||||
impl Setting for AssistantSettings {
|
||||
const KEY: Option<&'static str> = Some("assistant");
|
||||
|
||||
type FileContent = AssistantSettingsContent;
|
||||
|
||||
fn load(
|
||||
default_value: &Self::FileContent,
|
||||
user_values: &[&Self::FileContent],
|
||||
_: &gpui::AppContext,
|
||||
) -> anyhow::Result<Self> {
|
||||
Self::load_via_json_merge(default_value, user_values)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ pub mod items;
|
||||
mod link_go_to_definition;
|
||||
mod mouse_context_menu;
|
||||
pub mod movement;
|
||||
mod multi_buffer;
|
||||
pub mod multi_buffer;
|
||||
mod persistence;
|
||||
pub mod scroll;
|
||||
pub mod selections_collection;
|
||||
@@ -31,11 +31,13 @@ use copilot::Copilot;
|
||||
pub use display_map::DisplayPoint;
|
||||
use display_map::*;
|
||||
pub use editor_settings::EditorSettings;
|
||||
pub use element::RenderExcerptHeaderParams;
|
||||
pub use element::{
|
||||
Cursor, EditorElement, HighlightedRange, HighlightedRangeLine, LineWithInvisibles,
|
||||
};
|
||||
use futures::FutureExt;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::LayoutContext;
|
||||
use gpui::{
|
||||
actions,
|
||||
color::Color,
|
||||
@@ -507,7 +509,9 @@ pub struct Editor {
|
||||
blink_manager: ModelHandle<BlinkManager>,
|
||||
show_local_selections: bool,
|
||||
mode: EditorMode,
|
||||
show_gutter: bool,
|
||||
placeholder_text: Option<Arc<str>>,
|
||||
render_excerpt_header: Option<element::RenderExcerptHeader>,
|
||||
highlighted_rows: Option<Range<u32>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
background_highlights: BTreeMap<TypeId, (fn(&Theme) -> Color, Vec<Range<Anchor>>)>,
|
||||
@@ -537,6 +541,7 @@ pub struct Editor {
|
||||
|
||||
pub struct EditorSnapshot {
|
||||
pub mode: EditorMode,
|
||||
pub show_gutter: bool,
|
||||
pub display_snapshot: DisplaySnapshot,
|
||||
pub placeholder_text: Option<Arc<str>>,
|
||||
is_focused: bool,
|
||||
@@ -1310,7 +1315,9 @@ impl Editor {
|
||||
blink_manager: blink_manager.clone(),
|
||||
show_local_selections: true,
|
||||
mode,
|
||||
show_gutter: mode == EditorMode::Full,
|
||||
placeholder_text: None,
|
||||
render_excerpt_header: None,
|
||||
highlighted_rows: None,
|
||||
background_highlights: Default::default(),
|
||||
nav_history: None,
|
||||
@@ -1406,6 +1413,7 @@ impl Editor {
|
||||
pub fn snapshot(&mut self, cx: &mut WindowContext) -> EditorSnapshot {
|
||||
EditorSnapshot {
|
||||
mode: self.mode,
|
||||
show_gutter: self.show_gutter,
|
||||
display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)),
|
||||
scroll_anchor: self.scroll_manager.anchor(),
|
||||
ongoing_scroll: self.scroll_manager.ongoing_scroll(),
|
||||
@@ -2386,7 +2394,7 @@ impl Editor {
|
||||
old_selections
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let anchor = snapshot.anchor_after(s.end);
|
||||
let anchor = snapshot.anchor_after(s.head());
|
||||
s.map(|_| anchor)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -3561,7 +3569,9 @@ impl Editor {
|
||||
s.move_with(|map, selection| {
|
||||
if selection.is_empty() && !line_mode {
|
||||
let cursor = movement::right(map, selection.head());
|
||||
selection.set_head(cursor, SelectionGoal::None);
|
||||
selection.end = cursor;
|
||||
selection.reversed = true;
|
||||
selection.goal = SelectionGoal::None;
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -6764,6 +6774,25 @@ impl Editor {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_show_gutter(&mut self, show_gutter: bool, cx: &mut ViewContext<Self>) {
|
||||
self.show_gutter = show_gutter;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_render_excerpt_header(
|
||||
&mut self,
|
||||
render_excerpt_header: impl 'static
|
||||
+ Fn(
|
||||
&mut Editor,
|
||||
RenderExcerptHeaderParams,
|
||||
&mut LayoutContext<Editor>,
|
||||
) -> AnyElement<Editor>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.render_excerpt_header = Some(Arc::new(render_excerpt_header));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
|
||||
if let Some(buffer) = self.buffer().read(cx).as_singleton() {
|
||||
if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) {
|
||||
@@ -6988,7 +7017,7 @@ impl Editor {
|
||||
multi_buffer::Event::DiagnosticsUpdated => {
|
||||
self.refresh_active_diagnostics(cx);
|
||||
}
|
||||
multi_buffer::Event::LanguageChanged => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7402,8 +7431,12 @@ impl View for Editor {
|
||||
});
|
||||
}
|
||||
|
||||
let mut editor = EditorElement::new(style.clone());
|
||||
if let Some(render_excerpt_header) = self.render_excerpt_header.clone() {
|
||||
editor = editor.with_render_excerpt_header(render_excerpt_header);
|
||||
}
|
||||
Stack::new()
|
||||
.with_child(EditorElement::new(style.clone()))
|
||||
.with_child(editor)
|
||||
.with_child(ChildView::new(&self.mouse_context_menu, cx))
|
||||
.into_any()
|
||||
}
|
||||
|
||||
@@ -579,7 +579,7 @@ async fn test_navigation_history(cx: &mut TestAppContext) {
|
||||
assert_eq!(editor.scroll_manager.anchor(), original_scroll_position);
|
||||
|
||||
// Ensure we don't panic when navigation data contains invalid anchors *and* points.
|
||||
let mut invalid_anchor = editor.scroll_manager.anchor().top_anchor;
|
||||
let mut invalid_anchor = editor.scroll_manager.anchor().anchor;
|
||||
invalid_anchor.text_anchor.buffer_id = Some(999);
|
||||
let invalid_point = Point::new(9999, 0);
|
||||
editor.navigate(
|
||||
@@ -587,7 +587,7 @@ async fn test_navigation_history(cx: &mut TestAppContext) {
|
||||
cursor_anchor: invalid_anchor,
|
||||
cursor_position: invalid_point,
|
||||
scroll_anchor: ScrollAnchor {
|
||||
top_anchor: invalid_anchor,
|
||||
anchor: invalid_anchor,
|
||||
offset: Default::default(),
|
||||
},
|
||||
scroll_top_row: invalid_point.row,
|
||||
@@ -5277,7 +5277,28 @@ fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) {
|
||||
Point::new(0, 1)..Point::new(0, 1),
|
||||
Point::new(1, 1)..Point::new(1, 1),
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
// Ensure the cursor's head is respected when deleting across an excerpt boundary.
|
||||
view.change_selections(None, cx, |s| {
|
||||
s.select_ranges([Point::new(0, 2)..Point::new(1, 2)])
|
||||
});
|
||||
view.backspace(&Default::default(), cx);
|
||||
assert_eq!(view.text(cx), "Xa\nbbb");
|
||||
assert_eq!(
|
||||
view.selections.ranges(cx),
|
||||
[Point::new(1, 0)..Point::new(1, 0)]
|
||||
);
|
||||
|
||||
view.change_selections(None, cx, |s| {
|
||||
s.select_ranges([Point::new(1, 1)..Point::new(0, 1)])
|
||||
});
|
||||
view.backspace(&Default::default(), cx);
|
||||
assert_eq!(view.text(cx), "X\nbb");
|
||||
assert_eq!(
|
||||
view.selections.ranges(cx),
|
||||
[Point::new(0, 1)..Point::new(0, 1)]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5794,7 +5815,7 @@ async fn test_following(cx: &mut gpui::TestAppContext) {
|
||||
let top_anchor = follower.buffer().read(cx).read(cx).anchor_after(0);
|
||||
follower.set_scroll_anchor(
|
||||
ScrollAnchor {
|
||||
top_anchor,
|
||||
anchor: top_anchor,
|
||||
offset: vec2f(0.0, 0.5),
|
||||
},
|
||||
cx,
|
||||
|
||||
+152
-117
@@ -91,18 +91,41 @@ impl SelectionLayout {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RenderExcerptHeaderParams<'a> {
|
||||
pub id: crate::ExcerptId,
|
||||
pub buffer: &'a language::BufferSnapshot,
|
||||
pub range: &'a crate::ExcerptRange<text::Anchor>,
|
||||
pub starts_new_buffer: bool,
|
||||
pub gutter_padding: f32,
|
||||
pub editor_style: &'a EditorStyle,
|
||||
}
|
||||
|
||||
pub type RenderExcerptHeader = Arc<
|
||||
dyn Fn(
|
||||
&mut Editor,
|
||||
RenderExcerptHeaderParams,
|
||||
&mut LayoutContext<Editor>,
|
||||
) -> AnyElement<Editor>,
|
||||
>;
|
||||
|
||||
pub struct EditorElement {
|
||||
style: Arc<EditorStyle>,
|
||||
render_excerpt_header: RenderExcerptHeader,
|
||||
}
|
||||
|
||||
impl EditorElement {
|
||||
pub fn new(style: EditorStyle) -> Self {
|
||||
Self {
|
||||
style: Arc::new(style),
|
||||
render_excerpt_header: Arc::new(render_excerpt_header),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_render_excerpt_header(mut self, render: RenderExcerptHeader) -> Self {
|
||||
self.render_excerpt_header = render;
|
||||
self
|
||||
}
|
||||
|
||||
fn attach_mouse_handlers(
|
||||
scene: &mut SceneBuilder,
|
||||
position_map: &Arc<PositionMap>,
|
||||
@@ -1465,11 +1488,9 @@ impl EditorElement {
|
||||
line_height: f32,
|
||||
style: &EditorStyle,
|
||||
line_layouts: &[LineWithInvisibles],
|
||||
include_root: bool,
|
||||
editor: &mut Editor,
|
||||
cx: &mut LayoutContext<Editor>,
|
||||
) -> (f32, Vec<BlockLayout>) {
|
||||
let tooltip_style = theme::current(cx).tooltip.clone();
|
||||
let scroll_x = snapshot.scroll_anchor.offset.x();
|
||||
let (fixed_blocks, non_fixed_blocks) = snapshot
|
||||
.blocks_in_range(rows.clone())
|
||||
@@ -1510,112 +1531,18 @@ impl EditorElement {
|
||||
range,
|
||||
starts_new_buffer,
|
||||
..
|
||||
} => {
|
||||
let id = *id;
|
||||
let jump_icon = project::File::from_dyn(buffer.file()).map(|file| {
|
||||
let jump_path = ProjectPath {
|
||||
worktree_id: file.worktree_id(cx),
|
||||
path: file.path.clone(),
|
||||
};
|
||||
let jump_anchor = range
|
||||
.primary
|
||||
.as_ref()
|
||||
.map_or(range.context.start, |primary| primary.start);
|
||||
let jump_position = language::ToPoint::to_point(&jump_anchor, buffer);
|
||||
|
||||
enum JumpIcon {}
|
||||
MouseEventHandler::<JumpIcon, _>::new(id.into(), cx, |state, _| {
|
||||
let style = style.jump_icon.style_for(state, false);
|
||||
Svg::new("icons/arrow_up_right_8.svg")
|
||||
.with_color(style.color)
|
||||
.constrained()
|
||||
.with_width(style.icon_width)
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.constrained()
|
||||
.with_width(style.button_width)
|
||||
.with_height(style.button_width)
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, editor, cx| {
|
||||
if let Some(workspace) = editor
|
||||
.workspace
|
||||
.as_ref()
|
||||
.and_then(|(workspace, _)| workspace.upgrade(cx))
|
||||
{
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
Editor::jump(
|
||||
workspace,
|
||||
jump_path.clone(),
|
||||
jump_position,
|
||||
jump_anchor,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
.with_tooltip::<JumpIcon>(
|
||||
id.into(),
|
||||
"Jump to Buffer".to_string(),
|
||||
Some(Box::new(crate::OpenExcerpts)),
|
||||
tooltip_style.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned()
|
||||
.flex_float()
|
||||
});
|
||||
|
||||
if *starts_new_buffer {
|
||||
let style = &self.style.diagnostic_path_header;
|
||||
let font_size =
|
||||
(style.text_scale_factor * self.style.text.font_size).round();
|
||||
|
||||
let path = buffer.resolve_file_path(cx, include_root);
|
||||
let mut filename = None;
|
||||
let mut parent_path = None;
|
||||
// Can't use .and_then() because `.file_name()` and `.parent()` return references :(
|
||||
if let Some(path) = path {
|
||||
filename = path.file_name().map(|f| f.to_string_lossy().to_string());
|
||||
parent_path =
|
||||
path.parent().map(|p| p.to_string_lossy().to_string() + "/");
|
||||
}
|
||||
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Label::new(
|
||||
filename.unwrap_or_else(|| "untitled".to_string()),
|
||||
style.filename.text.clone().with_font_size(font_size),
|
||||
)
|
||||
.contained()
|
||||
.with_style(style.filename.container)
|
||||
.aligned(),
|
||||
)
|
||||
.with_children(parent_path.map(|path| {
|
||||
Label::new(path, style.path.text.clone().with_font_size(font_size))
|
||||
.contained()
|
||||
.with_style(style.path.container)
|
||||
.aligned()
|
||||
}))
|
||||
.with_children(jump_icon)
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.with_padding_left(gutter_padding)
|
||||
.with_padding_right(gutter_padding)
|
||||
.expanded()
|
||||
.into_any_named("path header block")
|
||||
} else {
|
||||
let text_style = self.style.text.clone();
|
||||
Flex::row()
|
||||
.with_child(Label::new("⋯", text_style))
|
||||
.with_children(jump_icon)
|
||||
.contained()
|
||||
.with_padding_left(gutter_padding)
|
||||
.with_padding_right(gutter_padding)
|
||||
.expanded()
|
||||
.into_any_named("collapsed context")
|
||||
}
|
||||
}
|
||||
} => (self.render_excerpt_header)(
|
||||
editor,
|
||||
RenderExcerptHeaderParams {
|
||||
id: *id,
|
||||
buffer,
|
||||
range,
|
||||
starts_new_buffer: *starts_new_buffer,
|
||||
gutter_padding,
|
||||
editor_style: style,
|
||||
},
|
||||
cx,
|
||||
),
|
||||
};
|
||||
|
||||
element.layout(
|
||||
@@ -1899,7 +1826,7 @@ impl Element<Editor> for EditorElement {
|
||||
let gutter_padding;
|
||||
let gutter_width;
|
||||
let gutter_margin;
|
||||
if snapshot.mode == EditorMode::Full {
|
||||
if snapshot.show_gutter {
|
||||
let em_width = style.text.em_width(cx.font_cache());
|
||||
gutter_padding = (em_width * style.gutter_padding_factor).round();
|
||||
gutter_width = self.max_line_number_width(&snapshot, cx) + gutter_padding * 2.0;
|
||||
@@ -2080,12 +2007,6 @@ impl Element<Editor> for EditorElement {
|
||||
ShowScrollbar::Never => false,
|
||||
};
|
||||
|
||||
let include_root = editor
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
|
||||
.unwrap_or_default();
|
||||
|
||||
let fold_ranges: Vec<(BufferRow, Range<DisplayPoint>, Color)> = fold_ranges
|
||||
.into_iter()
|
||||
.map(|(id, fold)| {
|
||||
@@ -2144,7 +2065,6 @@ impl Element<Editor> for EditorElement {
|
||||
line_height,
|
||||
&style,
|
||||
&line_layouts,
|
||||
include_root,
|
||||
editor,
|
||||
cx,
|
||||
);
|
||||
@@ -2759,6 +2679,121 @@ impl HighlightedRange {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_excerpt_header(
|
||||
editor: &mut Editor,
|
||||
RenderExcerptHeaderParams {
|
||||
id,
|
||||
buffer,
|
||||
range,
|
||||
starts_new_buffer,
|
||||
gutter_padding,
|
||||
editor_style,
|
||||
}: RenderExcerptHeaderParams,
|
||||
cx: &mut LayoutContext<Editor>,
|
||||
) -> AnyElement<Editor> {
|
||||
let tooltip_style = theme::current(cx).tooltip.clone();
|
||||
let include_root = editor
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
|
||||
.unwrap_or_default();
|
||||
let jump_icon = project::File::from_dyn(buffer.file()).map(|file| {
|
||||
let jump_path = ProjectPath {
|
||||
worktree_id: file.worktree_id(cx),
|
||||
path: file.path.clone(),
|
||||
};
|
||||
let jump_anchor = range
|
||||
.primary
|
||||
.as_ref()
|
||||
.map_or(range.context.start, |primary| primary.start);
|
||||
let jump_position = language::ToPoint::to_point(&jump_anchor, buffer);
|
||||
|
||||
enum JumpIcon {}
|
||||
MouseEventHandler::<JumpIcon, _>::new(id.into(), cx, |state, _| {
|
||||
let style = editor_style.jump_icon.style_for(state, false);
|
||||
Svg::new("icons/arrow_up_right_8.svg")
|
||||
.with_color(style.color)
|
||||
.constrained()
|
||||
.with_width(style.icon_width)
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.constrained()
|
||||
.with_width(style.button_width)
|
||||
.with_height(style.button_width)
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, editor, cx| {
|
||||
if let Some(workspace) = editor
|
||||
.workspace
|
||||
.as_ref()
|
||||
.and_then(|(workspace, _)| workspace.upgrade(cx))
|
||||
{
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
Editor::jump(workspace, jump_path.clone(), jump_position, jump_anchor, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.with_tooltip::<JumpIcon>(
|
||||
id.into(),
|
||||
"Jump to Buffer".to_string(),
|
||||
Some(Box::new(crate::OpenExcerpts)),
|
||||
tooltip_style.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned()
|
||||
.flex_float()
|
||||
});
|
||||
|
||||
if starts_new_buffer {
|
||||
let style = &editor_style.diagnostic_path_header;
|
||||
let font_size = (style.text_scale_factor * editor_style.text.font_size).round();
|
||||
|
||||
let path = buffer.resolve_file_path(cx, include_root);
|
||||
let mut filename = None;
|
||||
let mut parent_path = None;
|
||||
// Can't use .and_then() because `.file_name()` and `.parent()` return references :(
|
||||
if let Some(path) = path {
|
||||
filename = path.file_name().map(|f| f.to_string_lossy().to_string());
|
||||
parent_path = path.parent().map(|p| p.to_string_lossy().to_string() + "/");
|
||||
}
|
||||
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Label::new(
|
||||
filename.unwrap_or_else(|| "untitled".to_string()),
|
||||
style.filename.text.clone().with_font_size(font_size),
|
||||
)
|
||||
.contained()
|
||||
.with_style(style.filename.container)
|
||||
.aligned(),
|
||||
)
|
||||
.with_children(parent_path.map(|path| {
|
||||
Label::new(path, style.path.text.clone().with_font_size(font_size))
|
||||
.contained()
|
||||
.with_style(style.path.container)
|
||||
.aligned()
|
||||
}))
|
||||
.with_children(jump_icon)
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.with_padding_left(gutter_padding)
|
||||
.with_padding_right(gutter_padding)
|
||||
.expanded()
|
||||
.into_any_named("path header block")
|
||||
} else {
|
||||
let text_style = editor_style.text.clone();
|
||||
Flex::row()
|
||||
.with_child(Label::new("⋯", text_style))
|
||||
.with_children(jump_icon)
|
||||
.contained()
|
||||
.with_padding_left(gutter_padding)
|
||||
.with_padding_right(gutter_padding)
|
||||
.expanded()
|
||||
.into_any_named("collapsed context")
|
||||
}
|
||||
}
|
||||
|
||||
fn position_to_display_point(
|
||||
position: Vector2F,
|
||||
text_bounds: RectF,
|
||||
|
||||
@@ -196,7 +196,7 @@ impl FollowableItem for Editor {
|
||||
singleton: buffer.is_singleton(),
|
||||
title: (!buffer.is_singleton()).then(|| buffer.title(cx).into()),
|
||||
excerpts,
|
||||
scroll_top_anchor: Some(serialize_anchor(&scroll_anchor.top_anchor)),
|
||||
scroll_top_anchor: Some(serialize_anchor(&scroll_anchor.anchor)),
|
||||
scroll_x: scroll_anchor.offset.x(),
|
||||
scroll_y: scroll_anchor.offset.y(),
|
||||
selections: self
|
||||
@@ -253,7 +253,7 @@ impl FollowableItem for Editor {
|
||||
}
|
||||
Event::ScrollPositionChanged { .. } => {
|
||||
let scroll_anchor = self.scroll_manager.anchor();
|
||||
update.scroll_top_anchor = Some(serialize_anchor(&scroll_anchor.top_anchor));
|
||||
update.scroll_top_anchor = Some(serialize_anchor(&scroll_anchor.anchor));
|
||||
update.scroll_x = scroll_anchor.offset.x();
|
||||
update.scroll_y = scroll_anchor.offset.y();
|
||||
true
|
||||
@@ -412,7 +412,7 @@ async fn update_editor_from_message(
|
||||
} else if let Some(scroll_top_anchor) = scroll_top_anchor {
|
||||
editor.set_scroll_anchor_remote(
|
||||
ScrollAnchor {
|
||||
top_anchor: scroll_top_anchor,
|
||||
anchor: scroll_top_anchor,
|
||||
offset: vec2f(message.scroll_x, message.scroll_y),
|
||||
},
|
||||
cx,
|
||||
@@ -510,8 +510,8 @@ impl Item for Editor {
|
||||
};
|
||||
|
||||
let mut scroll_anchor = data.scroll_anchor;
|
||||
if !buffer.can_resolve(&scroll_anchor.top_anchor) {
|
||||
scroll_anchor.top_anchor = buffer.anchor_before(
|
||||
if !buffer.can_resolve(&scroll_anchor.anchor) {
|
||||
scroll_anchor.anchor = buffer.anchor_before(
|
||||
buffer.clip_point(Point::new(data.scroll_top_row, 0), Bias::Left),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ pub enum Event {
|
||||
ExcerptsRemoved {
|
||||
ids: Vec<ExcerptId>,
|
||||
},
|
||||
ExcerptsEdited {
|
||||
ids: Vec<ExcerptId>,
|
||||
},
|
||||
Edited,
|
||||
Reloaded,
|
||||
DiffBaseChanged,
|
||||
@@ -394,6 +397,7 @@ impl MultiBuffer {
|
||||
original_indent_column: u32,
|
||||
}
|
||||
let mut buffer_edits: HashMap<u64, Vec<BufferEdit>> = Default::default();
|
||||
let mut edited_excerpt_ids = Vec::new();
|
||||
let mut cursor = snapshot.excerpts.cursor::<usize>();
|
||||
for (ix, (range, new_text)) in edits.enumerate() {
|
||||
let new_text: Arc<str> = new_text.into();
|
||||
@@ -410,6 +414,7 @@ impl MultiBuffer {
|
||||
.start
|
||||
.to_offset(&start_excerpt.buffer)
|
||||
+ start_overshoot;
|
||||
edited_excerpt_ids.push(start_excerpt.id);
|
||||
|
||||
cursor.seek(&range.end, Bias::Right, &());
|
||||
if cursor.item().is_none() && range.end == *cursor.start() {
|
||||
@@ -435,6 +440,7 @@ impl MultiBuffer {
|
||||
original_indent_column,
|
||||
});
|
||||
} else {
|
||||
edited_excerpt_ids.push(end_excerpt.id);
|
||||
let start_excerpt_range = buffer_start
|
||||
..start_excerpt
|
||||
.range
|
||||
@@ -481,6 +487,7 @@ impl MultiBuffer {
|
||||
is_insertion: false,
|
||||
original_indent_column,
|
||||
});
|
||||
edited_excerpt_ids.push(excerpt.id);
|
||||
cursor.next(&());
|
||||
}
|
||||
}
|
||||
@@ -553,6 +560,10 @@ impl MultiBuffer {
|
||||
buffer.edit(insertions, insertion_autoindent_mode, cx);
|
||||
})
|
||||
}
|
||||
|
||||
cx.emit(Event::ExcerptsEdited {
|
||||
ids: edited_excerpt_ids,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn start_transaction(&mut self, cx: &mut ModelContext<Self>) -> Option<TransactionId> {
|
||||
|
||||
+11
-11
@@ -36,21 +36,21 @@ pub struct ScrollbarAutoHide(pub bool);
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ScrollAnchor {
|
||||
pub offset: Vector2F,
|
||||
pub top_anchor: Anchor,
|
||||
pub anchor: Anchor,
|
||||
}
|
||||
|
||||
impl ScrollAnchor {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
offset: Vector2F::zero(),
|
||||
top_anchor: Anchor::min(),
|
||||
anchor: Anchor::min(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_position(&self, snapshot: &DisplaySnapshot) -> Vector2F {
|
||||
let mut scroll_position = self.offset;
|
||||
if self.top_anchor != Anchor::min() {
|
||||
let scroll_top = self.top_anchor.to_display_point(snapshot).row() as f32;
|
||||
if self.anchor != Anchor::min() {
|
||||
let scroll_top = self.anchor.to_display_point(snapshot).row() as f32;
|
||||
scroll_position.set_y(scroll_top + scroll_position.y());
|
||||
} else {
|
||||
scroll_position.set_y(0.);
|
||||
@@ -59,7 +59,7 @@ impl ScrollAnchor {
|
||||
}
|
||||
|
||||
pub fn top_row(&self, buffer: &MultiBufferSnapshot) -> u32 {
|
||||
self.top_anchor.to_point(buffer).row
|
||||
self.anchor.to_point(buffer).row
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ impl ScrollManager {
|
||||
let (new_anchor, top_row) = if scroll_position.y() <= 0. {
|
||||
(
|
||||
ScrollAnchor {
|
||||
top_anchor: Anchor::min(),
|
||||
anchor: Anchor::min(),
|
||||
offset: scroll_position.max(vec2f(0., 0.)),
|
||||
},
|
||||
0,
|
||||
@@ -193,7 +193,7 @@ impl ScrollManager {
|
||||
|
||||
(
|
||||
ScrollAnchor {
|
||||
top_anchor,
|
||||
anchor: top_anchor,
|
||||
offset: vec2f(
|
||||
scroll_position.x(),
|
||||
scroll_position.y() - top_anchor.to_display_point(&map).row() as f32,
|
||||
@@ -322,7 +322,7 @@ impl Editor {
|
||||
hide_hover(self, cx);
|
||||
let workspace_id = self.workspace.as_ref().map(|workspace| workspace.1);
|
||||
let top_row = scroll_anchor
|
||||
.top_anchor
|
||||
.anchor
|
||||
.to_point(&self.buffer().read(cx).snapshot(cx))
|
||||
.row;
|
||||
self.scroll_manager
|
||||
@@ -337,7 +337,7 @@ impl Editor {
|
||||
hide_hover(self, cx);
|
||||
let workspace_id = self.workspace.as_ref().map(|workspace| workspace.1);
|
||||
let top_row = scroll_anchor
|
||||
.top_anchor
|
||||
.anchor
|
||||
.to_point(&self.buffer().read(cx).snapshot(cx))
|
||||
.row;
|
||||
self.scroll_manager
|
||||
@@ -377,7 +377,7 @@ impl Editor {
|
||||
let screen_top = self
|
||||
.scroll_manager
|
||||
.anchor
|
||||
.top_anchor
|
||||
.anchor
|
||||
.to_display_point(&snapshot);
|
||||
|
||||
if screen_top > newest_head {
|
||||
@@ -408,7 +408,7 @@ impl Editor {
|
||||
.anchor_at(Point::new(top_row as u32, 0), Bias::Left);
|
||||
let scroll_anchor = ScrollAnchor {
|
||||
offset: Vector2F::new(x, y),
|
||||
top_anchor,
|
||||
anchor: top_anchor,
|
||||
};
|
||||
self.set_scroll_anchor(scroll_anchor, cx);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ impl Editor {
|
||||
|
||||
editor.set_scroll_anchor(
|
||||
ScrollAnchor {
|
||||
top_anchor: new_anchor,
|
||||
anchor: new_anchor,
|
||||
offset: Default::default(),
|
||||
},
|
||||
cx,
|
||||
@@ -113,7 +113,7 @@ impl Editor {
|
||||
|
||||
editor.set_scroll_anchor(
|
||||
ScrollAnchor {
|
||||
top_anchor: new_anchor,
|
||||
anchor: new_anchor,
|
||||
offset: Default::default(),
|
||||
},
|
||||
cx,
|
||||
@@ -143,7 +143,7 @@ impl Editor {
|
||||
|
||||
editor.set_scroll_anchor(
|
||||
ScrollAnchor {
|
||||
top_anchor: new_anchor,
|
||||
anchor: new_anchor,
|
||||
offset: Default::default(),
|
||||
},
|
||||
cx,
|
||||
|
||||
@@ -76,6 +76,9 @@ impl SelectionsCollection {
|
||||
count
|
||||
}
|
||||
|
||||
/// The non-pending, non-overlapping selections. There could still be a pending
|
||||
/// selection that overlaps these if the mouse is being dragged, etc. Returned as
|
||||
/// selections over Anchors.
|
||||
pub fn disjoint_anchors(&self) -> Arc<[Selection<Anchor>]> {
|
||||
self.disjoint.clone()
|
||||
}
|
||||
|
||||
@@ -37,8 +37,6 @@ lazy_static.workspace = true
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
editor = { path = "../editor", features = ["test-support"] }
|
||||
gpui = { path = "../gpui", features = ["test-support"] }
|
||||
|
||||
@@ -60,6 +60,7 @@ pub struct Theme {
|
||||
pub incoming_call_notification: IncomingCallNotification,
|
||||
pub tooltip: TooltipStyle,
|
||||
pub terminal: TerminalStyle,
|
||||
pub assistant: AssistantStyle,
|
||||
pub feedback: FeedbackStyle,
|
||||
pub welcome: WelcomeStyle,
|
||||
pub color_scheme: ColorScheme,
|
||||
@@ -968,6 +969,23 @@ pub struct TerminalStyle {
|
||||
pub dim_foreground: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Default)]
|
||||
pub struct AssistantStyle {
|
||||
pub container: ContainerStyle,
|
||||
pub header: ContainerStyle,
|
||||
pub sent_at: ContainedText,
|
||||
pub user_sender: Interactive<ContainedText>,
|
||||
pub assistant_sender: Interactive<ContainedText>,
|
||||
pub system_sender: Interactive<ContainedText>,
|
||||
pub model_info_container: ContainerStyle,
|
||||
pub model: Interactive<ContainedText>,
|
||||
pub remaining_tokens: ContainedText,
|
||||
pub no_remaining_tokens: ContainedText,
|
||||
pub error_icon: Icon,
|
||||
pub api_key_editor: FieldEditor,
|
||||
pub api_key_prompt: ContainedText,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Default)]
|
||||
pub struct FeedbackStyle {
|
||||
pub submit_button: Interactive<ContainedText>,
|
||||
|
||||
@@ -400,7 +400,7 @@ fn scroll(editor: &mut Editor, amount: &ScrollAmount, cx: &mut ViewContext<Edito
|
||||
};
|
||||
|
||||
let scroll_margin_rows = editor.vertical_scroll_margin() as u32;
|
||||
let top_anchor = editor.scroll_manager.anchor().top_anchor;
|
||||
let top_anchor = editor.scroll_manager.anchor().anchor;
|
||||
|
||||
editor.change_selections(None, cx, |s| {
|
||||
s.replace_cursors_with(|snapshot| {
|
||||
|
||||
@@ -188,6 +188,12 @@ impl Dock {
|
||||
.map_or(false, |panel| panel.has_focus(cx))
|
||||
}
|
||||
|
||||
pub fn panel<T: Panel>(&self) -> Option<ViewHandle<T>> {
|
||||
self.panel_entries
|
||||
.iter()
|
||||
.find_map(|entry| entry.panel.as_any().clone().downcast())
|
||||
}
|
||||
|
||||
pub fn panel_index_for_type<T: Panel>(&self) -> Option<usize> {
|
||||
self.panel_entries
|
||||
.iter()
|
||||
|
||||
@@ -1151,7 +1151,8 @@ impl Pane {
|
||||
let theme = theme::current(cx).clone();
|
||||
let mut tooltip_theme = theme.tooltip.clone();
|
||||
tooltip_theme.max_text_width = None;
|
||||
let tab_tooltip_text = item.tab_tooltip_text(cx).map(|a| a.to_string());
|
||||
let tab_tooltip_text =
|
||||
item.tab_tooltip_text(cx).map(|text| text.into_owned());
|
||||
|
||||
move |mouse_state, cx| {
|
||||
let tab_style =
|
||||
|
||||
@@ -1673,6 +1673,16 @@ impl Workspace {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn panel<T: Panel>(&self, cx: &WindowContext) -> Option<ViewHandle<T>> {
|
||||
for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] {
|
||||
let dock = dock.read(cx);
|
||||
if let Some(panel) = dock.panel::<T>() {
|
||||
return Some(panel);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn zoom_out(&mut self, cx: &mut ViewContext<Self>) {
|
||||
for pane in &self.panes {
|
||||
pane.update(cx, |pane, cx| pane.set_zoomed(false, cx));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name = "Markdown"
|
||||
path_suffixes = ["md", "mdx", "zmd"]
|
||||
path_suffixes = ["md", "mdx"]
|
||||
brackets = [
|
||||
{ start = "{", end = "}", close = true, newline = true },
|
||||
{ start = "[", end = "]", close = true, newline = true },
|
||||
|
||||
+14
-2
@@ -4,6 +4,7 @@ pub mod menus;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test;
|
||||
|
||||
use ai::AssistantPanel;
|
||||
use anyhow::Context;
|
||||
use assets::Assets;
|
||||
use breadcrumbs::Breadcrumbs;
|
||||
@@ -253,6 +254,13 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
|
||||
workspace.toggle_panel_focus::<TerminalPanel>(cx);
|
||||
},
|
||||
);
|
||||
cx.add_action(
|
||||
|workspace: &mut Workspace,
|
||||
_: &ai::assistant::ToggleFocus,
|
||||
cx: &mut ViewContext<Workspace>| {
|
||||
workspace.toggle_panel_focus::<AssistantPanel>(cx);
|
||||
},
|
||||
);
|
||||
cx.add_global_action({
|
||||
let app_state = Arc::downgrade(&app_state);
|
||||
move |_: &NewWindow, cx: &mut AppContext| {
|
||||
@@ -358,7 +366,9 @@ pub fn initialize_workspace(
|
||||
|
||||
let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone());
|
||||
let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone());
|
||||
let (project_panel, terminal_panel) = futures::try_join!(project_panel, terminal_panel)?;
|
||||
let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone());
|
||||
let (project_panel, terminal_panel, assistant_panel) =
|
||||
futures::try_join!(project_panel, terminal_panel, assistant_panel)?;
|
||||
workspace_handle.update(&mut cx, |workspace, cx| {
|
||||
let project_panel_position = project_panel.position(cx);
|
||||
workspace.add_panel(project_panel, cx);
|
||||
@@ -376,7 +386,8 @@ pub fn initialize_workspace(
|
||||
workspace.toggle_dock(project_panel_position, cx);
|
||||
}
|
||||
|
||||
workspace.add_panel(terminal_panel, cx)
|
||||
workspace.add_panel(terminal_panel, cx);
|
||||
workspace.add_panel(assistant_panel, cx);
|
||||
})?;
|
||||
Ok(())
|
||||
})
|
||||
@@ -2189,6 +2200,7 @@ mod tests {
|
||||
pane::init(cx);
|
||||
project_panel::init(cx);
|
||||
terminal_view::init(cx);
|
||||
ai::init(cx);
|
||||
app_state
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ColorScheme } from "../themes/common/colorScheme"
|
||||
import feedback from "./feedback"
|
||||
import welcome from "./welcome"
|
||||
import copilot from "./copilot"
|
||||
import assistant from "./assistant"
|
||||
|
||||
export default function app(colorScheme: ColorScheme): Object {
|
||||
return {
|
||||
@@ -50,6 +51,7 @@ export default function app(colorScheme: ColorScheme): Object {
|
||||
simpleMessageNotification: simpleMessageNotification(colorScheme),
|
||||
tooltip: tooltip(colorScheme),
|
||||
terminal: terminal(colorScheme),
|
||||
assistant: assistant(colorScheme),
|
||||
feedback: feedback(colorScheme),
|
||||
colorScheme: {
|
||||
...colorScheme,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ColorScheme } from "../themes/common/colorScheme"
|
||||
import { text, border, background, foreground } from "./components"
|
||||
import editor from "./editor"
|
||||
|
||||
export default function assistant(colorScheme: ColorScheme) {
|
||||
const layer = colorScheme.highest;
|
||||
return {
|
||||
container: {
|
||||
background: editor(colorScheme).background,
|
||||
padding: { left: 12 }
|
||||
},
|
||||
header: {
|
||||
border: border(layer, "default", { bottom: true, top: true }),
|
||||
margin: { bottom: 6, top: 6 },
|
||||
background: editor(colorScheme).background
|
||||
},
|
||||
userSender: {
|
||||
...text(layer, "sans", "default", { size: "sm", weight: "bold" }),
|
||||
},
|
||||
assistantSender: {
|
||||
...text(layer, "sans", "accent", { size: "sm", weight: "bold" }),
|
||||
},
|
||||
systemSender: {
|
||||
...text(layer, "sans", "variant", { size: "sm", weight: "bold" }),
|
||||
},
|
||||
sentAt: {
|
||||
margin: { top: 2, left: 8 },
|
||||
...text(layer, "sans", "default", { size: "2xs" }),
|
||||
},
|
||||
modelInfoContainer: {
|
||||
margin: { right: 16, top: 4 },
|
||||
},
|
||||
model: {
|
||||
background: background(layer, "on"),
|
||||
border: border(layer, "on", { overlay: true }),
|
||||
padding: 4,
|
||||
cornerRadius: 4,
|
||||
...text(layer, "sans", "default", { size: "xs" }),
|
||||
hover: {
|
||||
background: background(layer, "on", "hovered"),
|
||||
}
|
||||
},
|
||||
remainingTokens: {
|
||||
background: background(layer, "on"),
|
||||
border: border(layer, "on", { overlay: true }),
|
||||
padding: 4,
|
||||
margin: { left: 4 },
|
||||
cornerRadius: 4,
|
||||
...text(layer, "sans", "positive", { size: "xs" }),
|
||||
},
|
||||
noRemainingTokens: {
|
||||
background: background(layer, "on"),
|
||||
border: border(layer, "on", { overlay: true }),
|
||||
padding: 4,
|
||||
margin: { left: 4 },
|
||||
cornerRadius: 4,
|
||||
...text(layer, "sans", "negative", { size: "xs" }),
|
||||
},
|
||||
errorIcon: {
|
||||
margin: { left: 8 },
|
||||
color: foreground(layer, "negative"),
|
||||
width: 12,
|
||||
},
|
||||
apiKeyEditor: {
|
||||
background: background(layer, "on"),
|
||||
cornerRadius: 6,
|
||||
text: text(layer, "mono", "on"),
|
||||
placeholderText: text(layer, "mono", "on", "disabled", {
|
||||
size: "xs",
|
||||
}),
|
||||
selection: colorScheme.players[0],
|
||||
border: border(layer, "on"),
|
||||
padding: {
|
||||
bottom: 4,
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 4,
|
||||
},
|
||||
},
|
||||
apiKeyPrompt: {
|
||||
padding: 10,
|
||||
...text(layer, "sans", "default", { size: "xs" }),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user