rules_library: Only store built-in prompts when they are customized (#45112)
Follow up to #45004 Release Notes: - N/A
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -12648,6 +12648,8 @@ dependencies = [
|
||||
"paths",
|
||||
"rope",
|
||||
"serde",
|
||||
"strum 0.27.2",
|
||||
"tempfile",
|
||||
"text",
|
||||
"util",
|
||||
"uuid",
|
||||
|
||||
@@ -426,7 +426,7 @@ impl NativeAgent {
|
||||
.into_iter()
|
||||
.flat_map(|(contents, prompt_metadata)| match contents {
|
||||
Ok(contents) => Some(UserRulesContext {
|
||||
uuid: prompt_metadata.id.user_id()?,
|
||||
uuid: prompt_metadata.id.as_user()?,
|
||||
title: prompt_metadata.title.map(|title| title.to_string()),
|
||||
contents,
|
||||
}),
|
||||
|
||||
@@ -1586,7 +1586,7 @@ pub(crate) fn search_rules(
|
||||
None
|
||||
} else {
|
||||
Some(RulesContextEntry {
|
||||
prompt_id: metadata.id.user_id()?,
|
||||
prompt_id: metadata.id.as_user()?,
|
||||
title: metadata.title?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ use project::{
|
||||
git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
|
||||
project_settings::{GitPathStyle, ProjectSettings},
|
||||
};
|
||||
use prompt_store::{PromptId, PromptStore, RULES_FILE_NAMES};
|
||||
use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsStore, StatusStyle};
|
||||
use std::future::Future;
|
||||
@@ -2579,25 +2579,26 @@ impl GitPanel {
|
||||
is_using_legacy_zed_pro: bool,
|
||||
cx: &mut AsyncApp,
|
||||
) -> String {
|
||||
const DEFAULT_PROMPT: &str = include_str!("commit_message_prompt.txt");
|
||||
|
||||
// Remove this once we stop supporting legacy Zed Pro
|
||||
// In legacy Zed Pro, Git commit summary generation did not count as a
|
||||
// prompt. If the user changes the prompt, our classification will fail,
|
||||
// meaning that users will be charged for generating commit messages.
|
||||
if is_using_legacy_zed_pro {
|
||||
return DEFAULT_PROMPT.to_string();
|
||||
return BuiltInPrompt::CommitMessage.default_content().to_string();
|
||||
}
|
||||
|
||||
let load = async {
|
||||
let store = cx.update(|cx| PromptStore::global(cx)).ok()?.await.ok()?;
|
||||
store
|
||||
.update(cx, |s, cx| s.load(PromptId::CommitMessage, cx))
|
||||
.update(cx, |s, cx| {
|
||||
s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx)
|
||||
})
|
||||
.ok()?
|
||||
.await
|
||||
.ok()
|
||||
};
|
||||
load.await.unwrap_or_else(|| DEFAULT_PROMPT.to_string())
|
||||
load.await
|
||||
.unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string())
|
||||
}
|
||||
|
||||
/// Generates a commit message using an LLM.
|
||||
|
||||
@@ -28,6 +28,11 @@ parking_lot.workspace = true
|
||||
paths.workspace = true
|
||||
rope.workspace = true
|
||||
serde.workspace = true
|
||||
strum.workspace = true
|
||||
text.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mod prompts;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::{DateTime, Utc};
|
||||
use collections::HashMap;
|
||||
use futures::FutureExt as _;
|
||||
@@ -23,6 +23,7 @@ use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
};
|
||||
use strum::{EnumIter, IntoEnumIterator as _};
|
||||
use text::LineEnding;
|
||||
use util::ResultExt;
|
||||
use uuid::Uuid;
|
||||
@@ -51,11 +52,51 @@ pub struct PromptMetadata {
|
||||
pub saved_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl PromptMetadata {
|
||||
fn builtin(builtin: BuiltInPrompt) -> Self {
|
||||
Self {
|
||||
id: PromptId::BuiltIn(builtin),
|
||||
title: Some(builtin.title().into()),
|
||||
default: false,
|
||||
saved_at: DateTime::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in prompts that have default content and can be customized by users.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)]
|
||||
pub enum BuiltInPrompt {
|
||||
CommitMessage,
|
||||
}
|
||||
|
||||
impl BuiltInPrompt {
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CommitMessage => "Commit message",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default content for this built-in prompt.
|
||||
pub fn default_content(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CommitMessage => include_str!("../../git_ui/src/commit_message_prompt.txt"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BuiltInPrompt {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::CommitMessage => write!(f, "Commit message"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum PromptId {
|
||||
User { uuid: UserPromptId },
|
||||
CommitMessage,
|
||||
BuiltIn(BuiltInPrompt),
|
||||
}
|
||||
|
||||
impl PromptId {
|
||||
@@ -63,31 +104,37 @@ impl PromptId {
|
||||
UserPromptId::new().into()
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> Option<UserPromptId> {
|
||||
pub fn as_user(&self) -> Option<UserPromptId> {
|
||||
match self {
|
||||
Self::User { uuid } => Some(*uuid),
|
||||
_ => None,
|
||||
Self::BuiltIn { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_built_in(&self) -> Option<BuiltInPrompt> {
|
||||
match self {
|
||||
Self::User { .. } => None,
|
||||
Self::BuiltIn(builtin) => Some(*builtin),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_built_in(&self) -> bool {
|
||||
match self {
|
||||
Self::User { .. } => false,
|
||||
Self::CommitMessage => true,
|
||||
}
|
||||
matches!(self, Self::BuiltIn { .. })
|
||||
}
|
||||
|
||||
pub fn can_edit(&self) -> bool {
|
||||
match self {
|
||||
Self::User { .. } | Self::CommitMessage => true,
|
||||
Self::User { .. } => true,
|
||||
Self::BuiltIn(builtin) => match builtin {
|
||||
BuiltInPrompt::CommitMessage => true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_content(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::User { .. } => None,
|
||||
Self::CommitMessage => Some(include_str!("../../git_ui/src/commit_message_prompt.txt")),
|
||||
}
|
||||
impl From<BuiltInPrompt> for PromptId {
|
||||
fn from(builtin: BuiltInPrompt) -> Self {
|
||||
PromptId::BuiltIn(builtin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +164,7 @@ impl std::fmt::Display for PromptId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PromptId::User { uuid } => write!(f, "{}", uuid.0),
|
||||
PromptId::CommitMessage => write!(f, "Commit message"),
|
||||
PromptId::BuiltIn(builtin) => write!(f, "{}", builtin),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,6 +197,16 @@ impl MetadataCache {
|
||||
cache.metadata.push(metadata.clone());
|
||||
cache.metadata_by_id.insert(prompt_id, metadata);
|
||||
}
|
||||
|
||||
// Insert all the built-in prompts that were not customized by the user
|
||||
for builtin in BuiltInPrompt::iter() {
|
||||
let builtin_id = PromptId::BuiltIn(builtin);
|
||||
if !cache.metadata_by_id.contains_key(&builtin_id) {
|
||||
let metadata = PromptMetadata::builtin(builtin);
|
||||
cache.metadata.push(metadata.clone());
|
||||
cache.metadata_by_id.insert(builtin_id, metadata);
|
||||
}
|
||||
}
|
||||
cache.sort();
|
||||
Ok(cache)
|
||||
}
|
||||
@@ -198,10 +255,6 @@ impl PromptStore {
|
||||
let mut txn = db_env.write_txn()?;
|
||||
let metadata = db_env.create_database(&mut txn, Some("metadata.v2"))?;
|
||||
let bodies = db_env.create_database(&mut txn, Some("bodies.v2"))?;
|
||||
|
||||
metadata.delete(&mut txn, &PromptId::CommitMessage)?;
|
||||
bodies.delete(&mut txn, &PromptId::CommitMessage)?;
|
||||
|
||||
txn.commit()?;
|
||||
|
||||
Self::upgrade_dbs(&db_env, metadata, bodies).log_err();
|
||||
@@ -294,7 +347,16 @@ impl PromptStore {
|
||||
let bodies = self.bodies;
|
||||
cx.background_spawn(async move {
|
||||
let txn = env.read_txn()?;
|
||||
let mut prompt = bodies.get(&txn, &id)?.context("prompt not found")?.into();
|
||||
let mut prompt: String = match bodies.get(&txn, &id)? {
|
||||
Some(body) => body.into(),
|
||||
None => {
|
||||
if let Some(built_in) = id.as_built_in() {
|
||||
built_in.default_content().into()
|
||||
} else {
|
||||
anyhow::bail!("prompt not found")
|
||||
}
|
||||
}
|
||||
};
|
||||
LineEnding::normalize(&mut prompt);
|
||||
Ok(prompt)
|
||||
})
|
||||
@@ -339,11 +401,6 @@ impl PromptStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the number of prompts in the store.
|
||||
pub fn prompt_count(&self) -> usize {
|
||||
self.metadata_cache.read().metadata.len()
|
||||
}
|
||||
|
||||
pub fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
|
||||
self.metadata_cache.read().metadata_by_id.get(&id).cloned()
|
||||
}
|
||||
@@ -412,23 +469,38 @@ impl PromptStore {
|
||||
return Task::ready(Err(anyhow!("this prompt cannot be edited")));
|
||||
}
|
||||
|
||||
let prompt_metadata = PromptMetadata {
|
||||
id,
|
||||
title,
|
||||
default,
|
||||
saved_at: Utc::now(),
|
||||
let body = body.to_string();
|
||||
let is_default_content = id
|
||||
.as_built_in()
|
||||
.is_some_and(|builtin| body.trim() == builtin.default_content().trim());
|
||||
|
||||
let metadata = if let Some(builtin) = id.as_built_in() {
|
||||
PromptMetadata::builtin(builtin)
|
||||
} else {
|
||||
PromptMetadata {
|
||||
id,
|
||||
title,
|
||||
default,
|
||||
saved_at: Utc::now(),
|
||||
}
|
||||
};
|
||||
self.metadata_cache.write().insert(prompt_metadata.clone());
|
||||
|
||||
self.metadata_cache.write().insert(metadata.clone());
|
||||
|
||||
let db_connection = self.env.clone();
|
||||
let bodies = self.bodies;
|
||||
let metadata = self.metadata;
|
||||
let metadata_db = self.metadata;
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
let mut txn = db_connection.write_txn()?;
|
||||
|
||||
metadata.put(&mut txn, &id, &prompt_metadata)?;
|
||||
bodies.put(&mut txn, &id, &body.to_string())?;
|
||||
if is_default_content {
|
||||
metadata_db.delete(&mut txn, &id)?;
|
||||
bodies.delete(&mut txn, &id)?;
|
||||
} else {
|
||||
metadata_db.put(&mut txn, &id, &metadata)?;
|
||||
bodies.put(&mut txn, &id, &body)?;
|
||||
}
|
||||
|
||||
txn.commit()?;
|
||||
|
||||
@@ -490,3 +562,122 @@ impl PromptStore {
|
||||
pub struct GlobalPromptStore(Shared<Task<Result<Entity<PromptStore>, Arc<anyhow::Error>>>>);
|
||||
|
||||
impl Global for GlobalPromptStore {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_built_in_prompt_load_save(cx: &mut TestAppContext) {
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("prompts-db");
|
||||
|
||||
let store = cx.update(|cx| PromptStore::new(db_path, cx)).await.unwrap();
|
||||
let store = cx.new(|_cx| store);
|
||||
|
||||
let commit_message_id = PromptId::BuiltIn(BuiltInPrompt::CommitMessage);
|
||||
|
||||
let loaded_content = store
|
||||
.update(cx, |store, cx| store.load(commit_message_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut expected_content = BuiltInPrompt::CommitMessage.default_content().to_string();
|
||||
LineEnding::normalize(&mut expected_content);
|
||||
assert_eq!(
|
||||
loaded_content.trim(),
|
||||
expected_content.trim(),
|
||||
"Loading a built-in prompt not in DB should return default content"
|
||||
);
|
||||
|
||||
let metadata = store.read_with(cx, |store, _| store.metadata(commit_message_id));
|
||||
assert!(
|
||||
metadata.is_some(),
|
||||
"Built-in prompt should always have metadata"
|
||||
);
|
||||
assert!(
|
||||
store.read_with(cx, |store, _| {
|
||||
store
|
||||
.metadata_cache
|
||||
.read()
|
||||
.metadata_by_id
|
||||
.contains_key(&commit_message_id)
|
||||
}),
|
||||
"Built-in prompt should always be in cache"
|
||||
);
|
||||
|
||||
let custom_content = "Custom commit message prompt";
|
||||
store
|
||||
.update(cx, |store, cx| {
|
||||
store.save(
|
||||
commit_message_id,
|
||||
Some("Commit message".into()),
|
||||
false,
|
||||
Rope::from(custom_content),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded_custom = store
|
||||
.update(cx, |store, cx| store.load(commit_message_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
loaded_custom.trim(),
|
||||
custom_content.trim(),
|
||||
"Custom content should be loaded after saving"
|
||||
);
|
||||
|
||||
assert!(
|
||||
store
|
||||
.read_with(cx, |store, _| store.metadata(commit_message_id))
|
||||
.is_some(),
|
||||
"Built-in prompt should have metadata after customization"
|
||||
);
|
||||
|
||||
store
|
||||
.update(cx, |store, cx| {
|
||||
store.save(
|
||||
commit_message_id,
|
||||
Some("Commit message".into()),
|
||||
false,
|
||||
Rope::from(BuiltInPrompt::CommitMessage.default_content()),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let metadata_after_reset =
|
||||
store.read_with(cx, |store, _| store.metadata(commit_message_id));
|
||||
assert!(
|
||||
metadata_after_reset.is_some(),
|
||||
"Built-in prompt should still have metadata after reset"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata_after_reset
|
||||
.as_ref()
|
||||
.and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
|
||||
Some("Commit message"),
|
||||
"Built-in prompt should have default title after reset"
|
||||
);
|
||||
|
||||
let loaded_after_reset = store
|
||||
.update(cx, |store, cx| store.load(commit_message_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut expected_content_after_reset =
|
||||
BuiltInPrompt::CommitMessage.default_content().to_string();
|
||||
LineEnding::normalize(&mut expected_content_after_reset);
|
||||
assert_eq!(
|
||||
loaded_after_reset.trim(),
|
||||
expected_content_after_reset.trim(),
|
||||
"After saving default content, load should return default"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ use collections::{HashMap, HashSet};
|
||||
use editor::{CompletionProvider, SelectionEffects};
|
||||
use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab};
|
||||
use gpui::{
|
||||
Action, App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable,
|
||||
PromptLevel, Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle,
|
||||
WindowOptions, actions, point, size, transparent_black,
|
||||
App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel,
|
||||
Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle, WindowOptions,
|
||||
actions, point, size, transparent_black,
|
||||
};
|
||||
use language::{Buffer, LanguageRegistry, language_settings::SoftWrap};
|
||||
use language_model::{
|
||||
@@ -21,7 +21,7 @@ use std::sync::atomic::AtomicBool;
|
||||
use std::time::Duration;
|
||||
use theme::ThemeSettings;
|
||||
use title_bar::platform_title_bar::PlatformTitleBar;
|
||||
use ui::{Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
|
||||
use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
|
||||
use util::{ResultExt, TryFutureExt};
|
||||
use workspace::{Workspace, WorkspaceSettings, client_side_decorations};
|
||||
use zed_actions::assistant::InlineAssist;
|
||||
@@ -206,13 +206,8 @@ impl PickerDelegate for RulePickerDelegate {
|
||||
self.filtered_entries.len()
|
||||
}
|
||||
|
||||
fn no_matches_text(&self, _window: &mut Window, cx: &mut App) -> Option<SharedString> {
|
||||
let text = if self.store.read(cx).prompt_count() == 0 {
|
||||
"No rules.".into()
|
||||
} else {
|
||||
"No rules found matching your search.".into()
|
||||
};
|
||||
Some(text)
|
||||
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
||||
Some("No rules found matching your search.".into())
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
@@ -680,13 +675,13 @@ impl RulesLibrary {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(default_content) = prompt_id.default_content() else {
|
||||
let Some(built_in) = prompt_id.as_built_in() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
|
||||
rule_editor.body_editor.update(cx, |editor, cx| {
|
||||
editor.set_text(default_content, window, cx);
|
||||
editor.set_text(built_in.default_content(), window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1428,31 +1423,7 @@ impl Render for RulesLibrary {
|
||||
this.border_t_1().border_color(cx.theme().colors().border)
|
||||
})
|
||||
.child(self.render_rule_list(cx))
|
||||
.map(|el| {
|
||||
if self.store.read(cx).prompt_count() == 0 {
|
||||
el.child(
|
||||
v_flex()
|
||||
.h_full()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.border_l_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(
|
||||
Button::new("create-rule", "New Rule")
|
||||
.style(ButtonStyle::Outlined)
|
||||
.key_binding(KeyBinding::for_action(&NewRule, cx))
|
||||
.on_click(|_, window, cx| {
|
||||
window
|
||||
.dispatch_action(NewRule.boxed_clone(), cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
el.child(self.render_active_rule(cx))
|
||||
}
|
||||
}),
|
||||
.child(self.render_active_rule(cx)),
|
||||
),
|
||||
window,
|
||||
cx,
|
||||
|
||||
Reference in New Issue
Block a user