From ac75ad992eccca62c3dd5bd2a675bb01f560a8bc Mon Sep 17 00:00:00 2001 From: calyptobai Date: Wed, 11 Oct 2023 17:09:21 -0400 Subject: [PATCH 01/21] Rework diffs --- Cargo.lock | 10 + server/bleep/Cargo.toml | 1 + server/bleep/sqlx-data.json | 24 + server/bleep/src/agent/prompts.rs | 88 +++ server/bleep/src/webserver.rs | 2 + server/bleep/src/webserver/repos.rs | 2 +- server/bleep/src/webserver/studio.rs | 491 ++++++++++++++- server/bleep/src/webserver/studio/diff.rs | 734 ++++++++++++++++++++++ 8 files changed, 1348 insertions(+), 4 deletions(-) create mode 100644 server/bleep/src/webserver/studio/diff.rs diff --git a/Cargo.lock b/Cargo.lock index f0afc0227e..6e3c70d13a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,6 +496,7 @@ dependencies = [ "comrak", "console-subscriber", "criterion", + "diffy", "directories", "either", "erased-serde", @@ -1511,6 +1512,15 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "diffy" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e616e59155c92257e84970156f506287853355f58cd4a6eb167385722c32b790" +dependencies = [ + "nu-ansi-term", +] + [[package]] name = "digest" version = "0.10.7" diff --git a/server/bleep/Cargo.toml b/server/bleep/Cargo.toml index 274bd06d2d..177af3de24 100644 --- a/server/bleep/Cargo.toml +++ b/server/bleep/Cargo.toml @@ -157,6 +157,7 @@ relative-path = "1.9.0" semver = { version = "1", features = ["serde"] } scc = { version= "1.9.1", features = ["serde"] } thread-priority = "0.13.1" +diffy = "0.3.0" [dev-dependencies] criterion = { version = "0.5.1", features = ["async_tokio"] } diff --git a/server/bleep/sqlx-data.json b/server/bleep/sqlx-data.json index fe1555a378..ab2885957b 100644 --- a/server/bleep/sqlx-data.json +++ b/server/bleep/sqlx-data.json @@ -572,6 +572,30 @@ }, "query": "DELETE FROM studio_snapshots\n WHERE id IN (\n SELECT ss.id\n FROM studio_snapshots ss\n JOIN studios s ON s.id = ss.studio_id AND s.user_id = ?\n WHERE ss.id = ? AND ss.studio_id = ?\n )\n RETURNING id" }, + "671df14b7c9077b95e586690f8c6d3f2eeb0a3942d0b800f272b010fcd2ca97b": { + "describe": { + "columns": [ + { + "name": "messages", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "context", + "ordinal": 1, + "type_info": "Text" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "SELECT messages, context FROM studio_snapshots WHERE id = ?" + }, "69c8b59ce4be3fc6edb58563bf69f55ea5dca4646b0ba05820e5d1b2b07c3c82": { "describe": { "columns": [ diff --git a/server/bleep/src/agent/prompts.rs b/server/bleep/src/agent/prompts.rs index 5d3a9d8199..ddc8dd1fed 100644 --- a/server/bleep/src/agent/prompts.rs +++ b/server/bleep/src/agent/prompts.rs @@ -298,6 +298,94 @@ And here is the serialized conversation: ) } +pub fn studio_diff_prompt(context_formatted: &str) -> String { + format!( + r#"Below are files from a codebase. Your job is to write a Unified Format patch to complete a provided task. To write a unified format patch, surround it in a code block: ```diff + +Follow these rules strictly: +- You MUST only return a single diff block, no additional commentary. +- Keep hunks concise only include a short context for each hunk. +- ALWAYS respect input whitespace, to ensure diffs can be applied cleanly! +- Only generate diffs that can be applied by `patch`! NO extra information like `git` commands +- To add a new file, set the input file as /dev/null +- To remove an existing file, set the output file to /dev/null + +# Example outputs + +```diff +--- src/index.js ++++ src/index.js +@@ -10,5 +10,5 @@ + const maybeHello = () => {{ + if (Math.random() > 0.5) {{ +- console.log("hello world!") ++ console.log("hello?") + }} + }} +``` + +```diff +--- README.md ++++ README.md +@@ -1,3 +1,3 @@ + # Bloop AI + +-bloop is ChatGPT for your code. Ask questions in natural language, search for code and generate patches using your existing codebase as context. ++bloop is ChatGPT for your code. Ask questions in natural language, search for code and generate patches using your existing code base as context. +``` + +```diff +--- client/src/locales/en.json ++++ client/src/locales/en.json +@@ -21,5 +21,5 @@ + "Report a bug": "Report a bug", + "Sign In": "Sign In", +- "Sign in with GitHub": "Sign in with GitHub", ++ "Sign in via GitHub": "Sign in via GitHub", + "Status": "Status", + "Submit bug report": "Submit bug report", +``` + +Adding a new file: + +```diff +--- /dev/null ++++ src/sum.rs +@@ -0,0 +1,3 @@ ++fn sum(a: f32, b: f32) -> f32 {{ ++ a + b ++}} +``` + +Removing an existing file: + +```diff +--- src/div.rs ++++ /dev/null +@@ -1,3 +0,0 @@ +-fn div(a: f32, b: f32) -> f32 {{ +- a / b +-}} +``` + +##### + +{context_formatted}"# + ) +} + +pub fn studio_diff_regen_hunk_prompt(context_formatted: &str) -> String { + format!( + r#"The provided diff contains no context lines. Output a new hunk with the correct 3 context lines. + +Here is the full context for reference: + +##### + +{context_formatted}"# + ) +} + pub fn hypothetical_document_prompt(query: &str) -> String { format!( r#"Write a code snippet that could hypothetically be returned by a code search engine as the answer to the query: {query} diff --git a/server/bleep/src/webserver.rs b/server/bleep/src/webserver.rs index dc6f470668..3d331895d6 100644 --- a/server/bleep/src/webserver.rs +++ b/server/bleep/src/webserver.rs @@ -104,6 +104,8 @@ pub async fn start(app: Application) -> anyhow::Result<()> { ) .route("/studio/import", post(studio::import)) .route("/studio/:studio_id/generate", get(studio::generate)) + .route("/studio/:studio_id/diff", get(studio::diff)) + .route("/studio/:studio_id/diff/apply", post(studio::diff_apply)) .route("/studio/:studio_id/snapshots", get(studio::list_snapshots)) .route( "/studio/:studio_id/snapshots/:snapshot_id", diff --git a/server/bleep/src/webserver/repos.rs b/server/bleep/src/webserver/repos.rs index 771502d6b4..b24e0a844f 100644 --- a/server/bleep/src/webserver/repos.rs +++ b/server/bleep/src/webserver/repos.rs @@ -320,7 +320,7 @@ pub(super) async fn delete_by_id( /// Synchronize a repo by its id pub(super) async fn sync( Query(RepoParams { repo }): Query, - State(app): State, + Extension(app): Extension, Extension(user): Extension, ) -> Result { // TODO: We can refactor `repo_pool` to also hold queued repos, instead of doing a calculation diff --git a/server/bleep/src/webserver/studio.rs b/server/bleep/src/webserver/studio.rs index 8352764af9..fd82c81a31 100644 --- a/server/bleep/src/webserver/studio.rs +++ b/server/bleep/src/webserver/studio.rs @@ -16,9 +16,11 @@ use chrono::NaiveDateTime; use futures::{pin_mut, stream, StreamExt, TryStreamExt}; use rayon::prelude::*; use reqwest::StatusCode; -use tracing::{debug, error}; +use tracing::{debug, error, warn}; use uuid::Uuid; +use self::diff::{DiffChunk, DiffHunk}; + use super::{middleware::User, Error}; use crate::{ agent::{exchange::Exchange, prompts}, @@ -28,6 +30,8 @@ use crate::{ webserver, Application, }; +mod diff; + const LLM_GATEWAY_MODEL: &str = "gpt-4-0613"; fn no_user_id() -> Error { @@ -866,6 +870,487 @@ async fn generate_llm_context( Ok(s) } +/// A set of structured diff definitions consumed by the front-end. +mod structured_diff { + use std::fmt; + + use crate::repo::RepoRef; + + #[derive(serde::Serialize, serde::Deserialize)] + pub struct Diff { + pub chunks: Vec, + } + + impl fmt::Display for Diff { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use std::fmt::Write; + + let mut s = String::new(); + + for c in &self.chunks { + writeln!(s, "--- {}\n+++ {}", c.file, c.file)?; + + for h in &c.hunks { + writeln!(s, "@@ -{},0 +{},0 @@", h.line_start, h.line_start)?; + write!(s, "{}", h.patch)?; + } + } + + for c in super::diff::relaxed_parse(&s) { + write!(f, "{}", c)?; + } + + Ok(()) + } + } + + #[derive(serde::Serialize, serde::Deserialize)] + pub struct Chunk { + pub file: String, + pub repo: RepoRef, + pub branch: Option, + pub lang: Option, + pub hunks: Vec, + + /// This field is additionally included for simplicity on the front-end. + pub raw_patch: String, + } + + #[derive(serde::Serialize, serde::Deserialize)] + pub struct Hunk { + pub line_start: usize, + pub patch: String, + } +} + +pub async fn diff( + app: Extension, + user: Extension, + Path(studio_id): Path, +) -> webserver::Result> { + let user_id = user.username().ok_or_else(no_user_id)?.to_string(); + + let snapshot_id = latest_snapshot_id(studio_id, &*app.sql, &user_id).await?; + + let llm_gateway = user + .llm_gateway(&app) + .await + .map_err(|e| Error::user(e).with_status(StatusCode::UNAUTHORIZED))? + .quota_gated(!app.env.is_cloud_instance()) + .model(LLM_GATEWAY_MODEL) + .temperature(0.0); + + let (messages_json, context_json) = sqlx::query!( + "SELECT messages, context FROM studio_snapshots WHERE id = ?", + snapshot_id, + ) + .fetch_optional(&*app.sql) + .await? + .map(|row| (row.messages, row.context)) + .ok_or_else(studio_not_found)?; + + let messages = serde_json::from_str::>(&messages_json).map_err(Error::internal)?; + + let context = + serde_json::from_str::>(&context_json).map_err(Error::internal)?; + + let user_message = messages + .iter() + .rev() + .find_map(|msg| match msg { + Message::User(m) => Some(m), + Message::Assistant(..) => None, + }) + .context("studio did not contain a user message")?; + + let assistant_message = messages + .iter() + .rev() + .find_map(|msg| match msg { + Message::User(..) => None, + Message::Assistant(m) => Some(m), + }) + .context("studio did not contain an assistant message")?; + + app.track_studio( + &user, + StudioEvent::new(studio_id, "diff") + .with_payload("context", &context) + .with_payload("user_message", &user_message) + .with_payload("assistant_message", &assistant_message), + ); + + let llm_context = generate_llm_context((*app).clone(), &context, &[]).await?; + + let system_prompt = prompts::studio_diff_prompt(&llm_context); + let user_message = format!("Create a patch for the task \"{user_message}\".\n\n\nHere is the solution:\n\n{assistant_message}"); + + let messages = vec![ + llm_gateway::api::Message::system(&system_prompt), + llm_gateway::api::Message::user(&user_message), + ]; + + let response = llm_gateway.chat(&messages, None).await?; + let diff_chunks = diff::extract(&response)?.collect::>(); + + let (repo, branch) = context + .first() + .map(|cf| (cf.repo.clone(), cf.branch.clone())) + // We make a hard assumption in the design of diffs that a studio can only contain files + // from one repository. This allows us to determine which repository to create new files + // or delete files in, without having to prefix file paths with repository names. + // + // If we can't find *any* files in the context to detect the current repository, + // creating/deleting a file like `index.js` is ambiguous, so we just return an error. + .context("could not determine studio repository, studio didn't contain any files")?; + + let valid_chunks = futures::stream::iter(diff_chunks) + .map(|mut chunk| { + let (repo, branch) = (repo.clone(), branch.clone()); + let app = (*app).clone(); + let llm_context = llm_context.clone(); + let llm_gateway = llm_gateway.clone(); + + async move { + match (&chunk.src, &chunk.dst) { + (Some(src), Some(dst)) => { + if src != dst { + error!( + "patch source and destination file were different: \ + got `{src}` and `{dst}`" + ); + + return Ok(None); + } + + chunk.hunks = rectify_hunks( + &app, + &llm_context, + &llm_gateway, + chunk.hunks.iter(), + src, + &repo, + branch.as_deref(), + ) + .await?; + + Ok(Some(chunk)) + } + + (Some(src), None) => { + if validate_delete_file(&app, &chunk, src, &repo, branch.as_deref()).await? + { + Ok(Some(chunk)) + } else { + Ok(None) + } + } + + (None, Some(dst)) => { + if validate_add_file(&app, &chunk, dst, &repo, branch.as_deref()).await? { + Ok(Some(chunk)) + } else { + Ok(None) + } + } + + (None, None) => { + error!("patch chunk had no file source or destination"); + Ok(None) + } + } + } + }) + .buffered(10) + .try_filter_map(|c: Option<_>| async move { Ok::<_, anyhow::Error>(c) }) + .try_collect::>() + .await + .context("failed to interpret diff chunks")?; + + let mut file_langs = HashMap::::new(); + let mut out = structured_diff::Diff { chunks: vec![] }; + + for chunk in valid_chunks { + let path = chunk.src.as_deref().or(chunk.dst.as_deref()).unwrap(); + let lang = if let Some(l) = file_langs.get(path) { + Some(l.clone()) + } else { + let detected_lang = if let Some(src) = &chunk.src { + let doc = app + .indexes + .file + .by_path(&repo, src, branch.as_deref()) + .await? + .context("path did not exist in the index")?; + + doc.lang + } else { + hyperpolyglot::detect(std::path::Path::new(&chunk.dst.as_deref().unwrap())) + .ok() + .flatten() + .map(|detection| detection.language().to_owned()) + }; + + if let Some(l) = detected_lang.clone() { + file_langs.insert(path.to_owned(), l); + } + + detected_lang + }; + + out.chunks.push(structured_diff::Chunk { + raw_patch: chunk.to_string(), + + lang: lang.clone(), + repo: repo.clone(), + branch: branch.clone(), + file: path.to_owned(), + hunks: chunk + .hunks + .into_iter() + .map(|hunk| structured_diff::Hunk { + line_start: hunk.src_line, + patch: hunk + .lines + .into_iter() + .map(|line| line.to_string()) + .collect::(), + }) + .collect(), + }); + } + + Ok(Json(out)) +} + +fn context_repo_branch(context: &[ContextFile]) -> Result<(RepoRef, Option)> { + let (repo, branch) = context + .first() + .map(|cf| (cf.repo.clone(), cf.branch.clone())) + // We make a hard assumption in the design of diffs that a studio can only contain files + // from one repository. This allows us to determine which repository to create new files + // or delete files in, without having to prefix file paths with repository names. + // + // If we can't find *any* files in the context to detect the current repository, + // creating/deleting a file like `index.js` is ambiguous, so we just return an error. + .context("could not determine studio repository, studio didn't contain any files")?; + + Ok((repo, branch)) +} + +async fn rectify_hunks( + app: &Application, + llm_context: &str, + llm_gateway: &llm_gateway::Client, + hunks: impl Iterator, + path: &str, + repo: &RepoRef, + branch: Option<&str>, +) -> Result> { + let file = app + .indexes + .file + .by_path(repo, path, branch) + .await? + .context("path did not exist in the index")?; + + let mut file_content = file.content; + + let mut out = Vec::new(); + + for (i, hunk) in hunks.enumerate() { + let mut singular_chunk = DiffChunk { + src: Some(path.to_owned()), + dst: Some(path.to_owned()), + hunks: vec![hunk.clone()], + }; + + let diff = singular_chunk.to_string(); + let patch = diffy::Patch::from_str(&diff).context("invalid patch")?; + + if let Ok(t) = diffy::apply(&file_content, &patch) { + file_content = t; + out.extend(singular_chunk.hunks); + } else { + debug!("fixing up patch:\n\n{hunk:?}\n\n{diff}"); + + singular_chunk.hunks[0].lines.retain(|line| match line { + diff::Line::AddLine(..) | diff::Line::DelLine(..) => true, + diff::Line::Context(..) => false, + }); + singular_chunk.fixup_hunks(); + + let diff = if singular_chunk.hunks[0] + .lines + .iter() + .all(|l| matches!(l, diff::Line::AddLine(..))) + { + let system_prompt = prompts::studio_diff_regen_hunk_prompt(llm_context); + let messages = vec![ + llm_gateway::api::Message::system(&system_prompt), + llm_gateway::api::Message::user(&singular_chunk.to_string()), + ]; + llm_gateway.chat(&messages, None).await? + } else { + singular_chunk.to_string() + }; + + let patch = diffy::Patch::from_str(&diff).context("redacted patch was invalid")?; + + if let Ok(t) = diffy::apply(&file_content, &patch) { + file_content = t; + out.extend(singular_chunk.hunks); + } else { + warn!("hunk {path}#{i} failed: {diff}"); + } + } + } + + Ok(out) +} + +async fn validate_delete_file( + app: &Application, + chunk: &DiffChunk, + path: &str, + repo: &RepoRef, + branch: Option<&str>, +) -> Result { + let Some(file) = app.indexes.file.by_path(repo, path, branch).await? else { + error!("diff tried to delete a file that doesn't exist: {path}"); + return Ok(false); + }; + + // We know our formatted diffs are valid syntax. + let diff = chunk.to_string(); + let patch = diffy::Patch::from_str(&diff).unwrap(); + + let Ok(final_content) = diffy::apply(&file.content, &patch) else { + error!("deletion diff did not cleanly apply to file: {path}"); + return Ok(false); + }; + + if !final_content.trim().is_empty() { + error!("deletion diff did not fully delete file contents"); + return Ok(false); + } + + Ok(true) +} + +async fn validate_add_file( + app: &Application, + chunk: &DiffChunk, + path: &str, + repo: &RepoRef, + branch: Option<&str>, +) -> Result { + if app + .indexes + .file + .by_path(repo, path, branch) + .await? + .is_some() + { + error!("diff tried to create a file that already exists: {path}"); + return Ok(false); + }; + + if chunk.hunks.iter().any(|h| { + h.lines + .iter() + .any(|l| !matches!(l, diff::Line::AddLine(..))) + }) { + error!("diff to create a new file had non-addition lines"); + Ok(false) + } else { + Ok(true) + } +} + +pub async fn diff_apply( + app: Extension, + user: Extension, + Path(studio_id): Path, + diff: String, +) -> webserver::Result<()> { + let user_id = user.username().ok_or_else(no_user_id)?.to_string(); + + let snapshot_id = latest_snapshot_id(studio_id, &*app.sql, &user_id).await?; + + let context_json = sqlx::query!( + "SELECT context FROM studio_snapshots WHERE id = ?", + snapshot_id, + ) + .fetch_optional(&*app.sql) + .await? + .map(|row| row.context) + .ok_or_else(studio_not_found)?; + + let context = + serde_json::from_str::>(&context_json).map_err(Error::internal)?; + + let diff_chunks = diff::relaxed_parse(&diff); + + let (repo, branch) = context_repo_branch(&context)?; + + for (i, chunk) in diff_chunks.enumerate() { + let mut file_content = if let Some(src) = &chunk.src { + app.indexes + .file + .by_path(&repo, src, branch.as_deref()) + .await? + .context("path did not exist in the index")? + .content + } else { + String::new() + }; + + for (j, hunk) in chunk.hunks.iter().enumerate() { + let mut singular_chunk = chunk.clone(); + singular_chunk.hunks = vec![hunk.clone()]; + + let diff = singular_chunk.to_string(); + let patch = diffy::Patch::from_str(&diff).context("invalid patch")?; + + match diffy::apply(&file_content, &patch) { + Ok(t) => file_content = t, + Err(e) => { + return Err( + Error::user(format!("chunk {i}, hunk {j} failed to apply: {e}")) + .with_status(StatusCode::BAD_REQUEST), + ) + } + } + } + + let Some(repo_path) = repo.local_path() else { + error!("cannot apply patch to remote repo"); + continue; + }; + + if let Some(dst) = &chunk.dst { + let file_path = repo_path.join(dst); + std::fs::write(file_path, file_content).context("failed to patch file on disk")?; + } else { + if !file_content.trim().is_empty() { + return Err(Error::user( + "diff deletes a file but not all contents were removed", + )); + } + + let file_path = repo_path.join(chunk.src.clone().unwrap()); + std::fs::remove_file(file_path).context("failed to delete file on disk")?; + } + } + + // Force a re-sync. + let _ = crate::webserver::repos::sync(Query(webserver::repos::RepoParams { repo }), app, user) + .await?; + + Ok(()) +} + /// If a given studio's name is `NULL`, try to auto-generate a name. /// /// If the requested studio already has a name, this is a no-op. @@ -1107,9 +1592,9 @@ fn canonicalize_context( context: impl Iterator, ) -> impl Iterator { context - .fold(HashMap::new(), |mut map, file| { + .fold(HashMap::<_, Vec<_>>::new(), |mut map, file| { let key = (file.path.clone(), file.branch.clone()); - map.entry(key).or_insert_with(Vec::new).push(file); + map.entry(key).or_default().push(file); map }) .into_values() diff --git a/server/bleep/src/webserver/studio/diff.rs b/server/bleep/src/webserver/studio/diff.rs new file mode 100644 index 0000000000..20feaf819f --- /dev/null +++ b/server/bleep/src/webserver/studio/diff.rs @@ -0,0 +1,734 @@ +use std::fmt; + +use anyhow::{bail, Result}; +use lazy_regex::regex; + +pub fn extract(chat_response: &str) -> Result> { + Ok(relaxed_parse(&extract_diff(chat_response)?) + // We eagerly collect the iterator, and re-create it, as our input string is created in and + // can't escape this function. This also allows us to catch parse errors earlier. + .collect::>() + .into_iter()) +} + +fn extract_diff(chat_response: &str) -> Result { + let fragments = regex!(r#"^```diff.*?^(.*?)^```.*?($|\z)"#sm) + .captures_iter(chat_response) + .map(|c| c.get(1).unwrap().as_str()) + .collect::(); + + if fragments.is_empty() { + bail!("chat response didn't contain any diff blocks"); + } else { + Ok(fragments) + } +} + +/// Parse a diff, allowing for some formatting errors. +pub fn relaxed_parse(diff: &str) -> impl Iterator + '_ { + split_chunks(diff).map(|mut chunk| { + dbg!(&chunk); + chunk.fixup_hunks(); + chunk + }) +} + +fn split_chunks(diff: &str) -> impl Iterator + '_ { + let chunk_regex = regex!(r#"(?: (.*)$\n^\+\+\+ (.*)$)\n((?:^$\n?|^[-+@ ].*\n?)+)"#m); + + regex!("^---"m).split(diff).filter_map(|chunk| { + let caps = chunk_regex.captures(chunk)?; + Some(DiffChunk { + src: match caps.get(1).unwrap().as_str() { + "/dev/null" => None, + s => Some(s.to_owned()), + }, + dst: match caps.get(2).unwrap().as_str() { + "/dev/null" => None, + s => Some(s.to_owned()), + }, + hunks: split_hunks(caps.get(3).unwrap().as_str()).collect(), + }) + }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DiffChunk { + pub src: Option, + pub dst: Option, + pub hunks: Vec, +} + +impl DiffChunk { + pub fn fixup_hunks(&mut self) { + self.hunks.retain_mut(|h| { + if !h.fixup() { + false + } else { + h.lines.iter().any(|l| !matches!(l, Line::Context(_))) + } + }); + } +} + +impl fmt::Display for DiffChunk { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let hunks_str = self.hunks.iter().map(|h| h.to_string()).collect::(); + + let src = if let Some(s) = self.src.as_deref() { + s + } else { + "/dev/null" + }; + + let dst = if let Some(s) = self.dst.as_deref() { + s + } else { + "/dev/null" + }; + + write!(f, "--- {src}\n+++ {dst}\n{hunks_str}") + } +} + +fn split_hunks(hunks: &str) -> impl Iterator + '_ { + let hunk_regex = + regex!(r#"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@.*\n((?:^\n|^[-+ ].*\n?)*)"#m); + + hunk_regex.captures_iter(hunks).map(|caps| DiffHunk { + src_line: caps.get(1).unwrap().as_str().parse().unwrap(), + src_count: caps + .get(2) + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0), + dst_line: caps.get(3).unwrap().as_str().parse().unwrap(), + dst_count: caps + .get(4) + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0), + lines: { + caps.get(5) + .unwrap() + .as_str() + .lines() + .map(|l| { + if !l.is_empty() { + l.split_at(1) + } else { + (" ", "") + } + }) + .map(|(type_, line)| match type_ { + " " => Line::Context(line.into()), + "+" => Line::AddLine(line.into()), + "-" => Line::DelLine(line.into()), + _ => unreachable!("unknown character slipped through regex"), + }) + .collect() + }, + }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DiffHunk { + pub src_line: usize, + pub dst_line: usize, + pub src_count: usize, + pub dst_count: usize, + + pub lines: Vec, +} + +impl DiffHunk { + fn fixup(&mut self) -> bool { + let src = self + .lines + .iter() + .filter_map(|line| match line { + Line::Context(l) => Some(format!("{l}\n")), + Line::AddLine(_) => None, + Line::DelLine(l) => Some(format!("{l}\n")), + }) + .collect::(); + + let dst = self + .lines + .iter() + .filter_map(|line| match line { + Line::Context(l) => Some(format!("{l}\n")), + Line::AddLine(l) => Some(format!("{l}\n")), + Line::DelLine(_) => None, + }) + .collect::(); + + let patch = diffy::DiffOptions::default() + .set_context_len(usize::MAX) + .create_patch(&src, &dst); + let patch = patch.to_string(); + + let mut new_hunks = split_hunks(&patch).collect::>(); + + if new_hunks.is_empty() { + return false; + } + + assert_eq!( + new_hunks.len(), + 1, + "regenerated hunk's patch was malformed:\n\n{patch}" + ); + self.lines = new_hunks.pop().unwrap().lines.into_iter().collect(); + + self.src_count = self + .lines + .iter() + .map(|l| match l { + Line::Context(_) | Line::DelLine(_) => 1, + Line::AddLine(_) => 0, + }) + .sum(); + + self.dst_count = self + .lines + .iter() + .map(|l| match l { + Line::Context(_) | Line::AddLine(_) => 1, + Line::DelLine(_) => 0, + }) + .sum(); + + true + } +} + +impl fmt::Display for DiffHunk { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!( + f, + "@@ -{},{} +{},{} @@", + self.src_line, self.src_count, self.dst_line, self.dst_count + )?; + + for line in &self.lines { + ::fmt(line, f)?; + } + + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Line { + Context(String), + AddLine(String), + DelLine(String), +} + +impl fmt::Display for Line { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Line::Context(line) => writeln!(f, " {line}"), + Line::AddLine(line) => writeln!(f, "+{line}"), + Line::DelLine(line) => writeln!(f, "-{line}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn test_extract_diff() { + let s = "```diff +foo bar +```"; + + assert_eq!(extract_diff(s).unwrap(), "foo bar\n"); + } + + #[test] + fn test_extract_diff_complex() { + let s = "```diff +x + + ```diff +foo bar + ``` +```"; + + assert_eq!( + extract_diff(s).unwrap(), + "x\n\n ```diff\nfoo bar\n ```\n" + ); + } + + #[test] + fn test_relaxed_parse() { + let s = "\ +--- foo.rs ++++ foo.rs +@@ -1,1 +1,1 @@ +-foo ++bar +@@ -10,1 +10,1 @@ + quux + quux2 ++quux3 + quux4 +--- bar.rs ++++ bar.rs +@@ -10 +10 @@ +-bar ++fred +@@ -100,1 +100,1 @@ + baz +-bar ++thud + baz ++thud + baz"; + + let expected = "\ +--- foo.rs ++++ foo.rs +@@ -1,1 +1,1 @@ +-foo ++bar +@@ -10,3 +10,4 @@ + quux + quux2 ++quux3 + quux4 +--- bar.rs ++++ bar.rs +@@ -10,1 +10,1 @@ +-bar ++fred +@@ -100,4 +100,5 @@ + baz +-bar ++thud + baz ++thud + baz +"; + let output = relaxed_parse(s) + .map(|chunk| chunk.to_string()) + .collect::(); + assert_eq!(expected, output); + } + + #[test] + fn test_split_hunks() { + let hunks = "@@ -1,1 +1,1 @@ + context + the line right below this one is intentionally empty + +-foo ++bar +@@ -10,1 +10,2 @@ +-bar ++quux ++quux2"; + + let expected = vec![ + DiffHunk { + src_line: 1, + src_count: 1, + dst_line: 1, + dst_count: 1, + lines: vec![ + Line::Context("context".to_owned()), + Line::Context( + "the line right below this one is intentionally empty".to_owned(), + ), + Line::Context("".to_owned()), + Line::DelLine("foo".to_owned()), + Line::AddLine("bar".to_owned()), + ], + }, + DiffHunk { + src_line: 10, + src_count: 1, + dst_line: 10, + dst_count: 2, + lines: vec![ + Line::DelLine("bar".to_owned()), + Line::AddLine("quux".to_owned()), + Line::AddLine("quux2".to_owned()), + ], + }, + ]; + + let output = split_hunks(hunks).collect::>(); + + assert_eq!(expected, output); + } + + #[test] + fn test_split_chunks() { + let diff = " A simple diff description. + +--- foo.rs ++++ foo.rs +@@ -1,1 +1,1 @@ + context + the line right below this one is intentionally empty + +-foo ++bar +--- bar.rs ++++ bar.rs +@@ -10,1 +10,2 @@ +-bar ++quux ++quux2"; + + let expected = vec![ + DiffChunk { + src: Some("foo.rs".to_owned()), + dst: Some("foo.rs".to_owned()), + hunks: vec![DiffHunk { + src_line: 1, + src_count: 1, + dst_line: 1, + dst_count: 1, + lines: vec![ + Line::Context("context".to_owned()), + Line::Context( + "the line right below this one is intentionally empty".to_owned(), + ), + Line::Context("".to_owned()), + Line::DelLine("foo".to_owned()), + Line::AddLine("bar".to_owned()), + ], + }], + }, + DiffChunk { + src: Some("bar.rs".to_owned()), + dst: Some("bar.rs".to_owned()), + hunks: vec![DiffHunk { + src_line: 10, + src_count: 1, + dst_line: 10, + dst_count: 2, + lines: vec![ + Line::DelLine("bar".to_owned()), + Line::AddLine("quux".to_owned()), + Line::AddLine("quux2".to_owned()), + ], + }], + }, + ]; + + let output = split_chunks(diff).collect::>(); + + assert_eq!(expected, output); + } + + #[test] + fn test_bug_split() { + let chat_response = r#"```diff +--- server/bleep/src/analytics.rs ++++ server/bleep/src/analytics.rs +@@ -215,6 +215,22 @@ impl RudderHub { + })); + } + } ++ ++ pub fn track_index_repo(&self, user: &crate::webserver::middleware::User, repo_ref: RepoRef) { ++ if let Some(options) = &self.options { ++ self.send(Message::Track(Track { ++ user_id: Some(self.tracking_id(user.username())), ++ event: "index repo".to_owned(), ++ properties: Some(json!({ ++ "device_id": self.device_id(), ++ "repo_ref": repo_ref.to_string(), ++ "package_metadata": options.package_metadata, ++ })), ++ ..Default::default() ++ })); ++ } ++ } + } + + impl From> for DeviceId { + +--- server/bleep/src/indexes.rs ++++ server/bleep/src/indexes.rs +@@ -61,7 +61,9 @@ impl<'a> GlobalWriteHandle<'a> { + } + + pub(crate) async fn index( +- &self, ++ &self, ++ analytics: &RudderHub, // Pass in the RudderHub instance ++ user: &crate::webserver::middleware::User, // Pass in the current user + sync_handle: &SyncHandle, + repo: &Repository, + ) -> Result, RepoError> { +@@ -70,6 +72,9 @@ impl<'a> GlobalWriteHandle<'a> { + + for h in &self.handles { + h.index(sync_handle, repo, &metadata).await?; ++ ++ // Track the repo indexing event ++ analytics.track_index_repo(user, repo.repo_ref.clone()); + } + + Ok(metadata) +```"#; + let expected = vec![ + DiffChunk { + src: Some("server/bleep/src/analytics.rs".to_owned()), + dst: Some("server/bleep/src/analytics.rs".to_owned()), + hunks: vec![DiffHunk { + src_line: 215, + src_count: 7, + dst_line: 215, + dst_count: 22, + lines: vec![ + Line::Context(" }));".to_owned()), + Line::Context(" }".to_owned()), + Line::Context(" }".to_owned()), + Line::AddLine(" ".to_owned()), + Line::AddLine(" pub fn track_index_repo(&self, user: &crate::webserver::middleware::User, repo_ref: RepoRef) {".to_owned()), + Line::AddLine(r#" if let Some(options) = &self.options {"#.to_owned()), + Line::AddLine(r#" self.send(Message::Track(Track {"#.to_owned()), + Line::AddLine(r#" user_id: Some(self.tracking_id(user.username())),"#.to_owned()), + Line::AddLine(r#" event: "index repo".to_owned(),"#.to_owned()), + Line::AddLine(r#" properties: Some(json!({"#.to_owned()), + Line::AddLine(r#" "device_id": self.device_id(),"#.to_owned()), + Line::AddLine(r#" "repo_ref": repo_ref.to_string(),"#.to_owned()), + Line::AddLine(r#" "package_metadata": options.package_metadata,"#.to_owned()), + Line::AddLine(r#" })),"#.to_owned()), + Line::AddLine(r#" ..Default::default()"#.to_owned()), + Line::AddLine(r#" }));"#.to_owned()), + Line::AddLine(r#" }"#.to_owned()), + Line::AddLine(r#" }"#.to_owned()), + Line::Context("}".to_owned()), + Line::Context("".to_owned()), + Line::Context("impl From> for DeviceId {".to_owned()), + Line::Context("".to_owned()), + ], + }], + }, + DiffChunk { + src: Some("server/bleep/src/indexes.rs".to_owned()), + dst: Some("server/bleep/src/indexes.rs".to_owned()), + hunks: vec![ + DiffHunk { + src_line: 61, + src_count: 7, + dst_line: 61, + dst_count: 9, + lines: vec![ + Line::Context(r#" }"#.to_owned()), + Line::Context(r#""#.to_owned()), + Line::Context(r#" pub(crate) async fn index("#.to_owned()), + Line::Context(r#" &self,"#.to_owned()), + Line::AddLine(r#" analytics: &RudderHub, // Pass in the RudderHub instance"#.to_owned()), + Line::AddLine(r#" user: &crate::webserver::middleware::User, // Pass in the current user"#.to_owned()), + Line::Context(r#" sync_handle: &SyncHandle,"#.to_owned()), + Line::Context(r#" repo: &Repository,"#.to_owned()), + Line::Context(r#" ) -> Result, RepoError> {"#.to_owned()), + ], + }, + DiffHunk { + src_line: 70, + src_count: 6, + dst_line: 72, + dst_count: 9, + lines: vec![ + Line::Context(r#""#.to_owned()), + Line::Context(r#" for h in &self.handles {"#.to_owned()), + Line::Context(r#" h.index(sync_handle, repo, &metadata).await?;"#.to_owned()), + Line::AddLine(r#" "#.to_owned()), + Line::AddLine(r#" // Track the repo indexing event"#.to_owned()), + Line::AddLine(r#" analytics.track_index_repo(user, repo.repo_ref.clone());"#.to_owned()), + Line::Context(r#" }"#.to_owned()), + Line::Context(r#""#.to_owned()), + Line::Context(r#" Ok(metadata)"#.to_owned()), + ], + }, + ], + }, + ]; + + let output = extract(chat_response).unwrap().collect::>(); + + assert_eq!(expected, output); + } + + #[test] + fn test_split_chunks_no_count() {} + + #[test] + fn test_fixup_remove_redundancy() { + let mut hunk = DiffHunk { + src_line: 10, + src_count: 5, + dst_line: 10, + dst_count: 5, + lines: vec![ + Line::DelLine("fn main() {".to_owned()), + Line::AddLine("fn main() {".to_owned()), + Line::Context(" let a = 123;".to_owned()), + Line::DelLine(" println!(\"the value of `a` is {a:?}\");".to_owned()), + Line::AddLine(" dbg!(&a);".to_owned()), + Line::Context(" drop(a);".to_owned()), + Line::Context("}".to_owned()), + ], + }; + + hunk.fixup(); + + let expected = DiffHunk { + src_line: 10, + src_count: 5, + dst_line: 10, + dst_count: 5, + lines: vec![ + Line::Context("fn main() {".to_owned()), + Line::Context(" let a = 123;".to_owned()), + Line::DelLine(" println!(\"the value of `a` is {a:?}\");".to_owned()), + Line::AddLine(" dbg!(&a);".to_owned()), + Line::Context(" drop(a);".to_owned()), + Line::Context("}".to_owned()), + ], + }; + + assert_eq!(expected, hunk); + } + + #[test] + fn test_extract_redundant() { + let chat_response = "```diff +--- server/bleep/src/query/parser.rs ++++ server/bleep/src/query/parser.rs +@@ -64,7 +64,7 @@ + } + + pub fn from_str(query: String, repo_ref: String) -> Self { +- Self { ++ Self { + target: Some(Literal::Plain(Cow::Owned(query))), + repos: [Literal::Plain(Cow::Owned(repo_ref))].into(), + ..Default::default() +```"; + + for _ in extract(&chat_response).unwrap() {} + } + + #[test] + fn test_multiple_diff_blocks() { + let chat_response = r#"```diff +--- server/bleep/src/analytics.rs ++++ server/bleep/src/analytics.rs +@@ -215,6 +215,22 @@ impl RudderHub { + })); + } + } ++ ++ pub fn track_index_repo(&self, user: &crate::webserver::middleware::User, repo_ref: RepoRef) { ++ if let Some(options) = &self.options { ++ self.send(Message::Track(Track { ++ user_id: Some(self.tracking_id(user.username())), ++ event: "index repo".to_owned(), ++ properties: Some(json!({ ++ "device_id": self.device_id(), ++ "repo_ref": repo_ref.to_string(), ++ "package_metadata": options.package_metadata, ++ })), ++ ..Default::default() ++ })); ++ } ++ } + } + + impl From> for DeviceId { +``` + +```diff +--- server/bleep/src/indexes.rs ++++ server/bleep/src/indexes.rs +@@ -61,7 +61,9 @@ impl<'a> GlobalWriteHandle<'a> { + } + + pub(crate) async fn index( +- &self, ++ &self, ++ analytics: &RudderHub, // Pass in the RudderHub instance ++ user: &crate::webserver::middleware::User, // Pass in the current user + sync_handle: &SyncHandle, + repo: &Repository, + ) -> Result, RepoError> { +@@ -70,6 +72,9 @@ impl<'a> GlobalWriteHandle<'a> { + + for h in &self.handles { + h.index(sync_handle, repo, &metadata).await?; ++ ++ // Track the repo indexing event ++ analytics.track_index_repo(user, repo.repo_ref.clone()); + } + + Ok(metadata) +```"#; + + let expected = r#"--- server/bleep/src/analytics.rs ++++ server/bleep/src/analytics.rs +@@ -215,6 +215,22 @@ impl RudderHub { + })); + } + } ++ ++ pub fn track_index_repo(&self, user: &crate::webserver::middleware::User, repo_ref: RepoRef) { ++ if let Some(options) = &self.options { ++ self.send(Message::Track(Track { ++ user_id: Some(self.tracking_id(user.username())), ++ event: "index repo".to_owned(), ++ properties: Some(json!({ ++ "device_id": self.device_id(), ++ "repo_ref": repo_ref.to_string(), ++ "package_metadata": options.package_metadata, ++ })), ++ ..Default::default() ++ })); ++ } ++ } + } + + impl From> for DeviceId { +--- server/bleep/src/indexes.rs ++++ server/bleep/src/indexes.rs +@@ -61,7 +61,9 @@ impl<'a> GlobalWriteHandle<'a> { + } + + pub(crate) async fn index( +- &self, ++ &self, ++ analytics: &RudderHub, // Pass in the RudderHub instance ++ user: &crate::webserver::middleware::User, // Pass in the current user + sync_handle: &SyncHandle, + repo: &Repository, + ) -> Result, RepoError> { +@@ -70,6 +72,9 @@ impl<'a> GlobalWriteHandle<'a> { + + for h in &self.handles { + h.index(sync_handle, repo, &metadata).await?; ++ ++ // Track the repo indexing event ++ analytics.track_index_repo(user, repo.repo_ref.clone()); + } + + Ok(metadata) +"#; + + let output = extract_diff(chat_response).unwrap(); + + assert_eq!(expected, output); + } +} From cb8b7f5ccf079c2956eabcc2abddc1f11a50162c Mon Sep 17 00:00:00 2001 From: calyptobai Date: Wed, 15 Nov 2023 11:27:30 -0500 Subject: [PATCH 02/21] Don't validate file on deletion --- server/bleep/src/webserver/studio.rs | 32 ++++++++++------------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/server/bleep/src/webserver/studio.rs b/server/bleep/src/webserver/studio.rs index fd82c81a31..e20465482d 100644 --- a/server/bleep/src/webserver/studio.rs +++ b/server/bleep/src/webserver/studio.rs @@ -1038,8 +1038,7 @@ pub async fn diff( } (Some(src), None) => { - if validate_delete_file(&app, &chunk, src, &repo, branch.as_deref()).await? - { + if validate_delete_file(&app, src, &repo, branch.as_deref()).await? { Ok(Some(chunk)) } else { Ok(None) @@ -1211,31 +1210,22 @@ async fn rectify_hunks( async fn validate_delete_file( app: &Application, - chunk: &DiffChunk, path: &str, repo: &RepoRef, branch: Option<&str>, ) -> Result { - let Some(file) = app.indexes.file.by_path(repo, path, branch).await? else { + if app + .indexes + .file + .by_path(repo, path, branch) + .await? + .is_none() + { error!("diff tried to delete a file that doesn't exist: {path}"); - return Ok(false); - }; - - // We know our formatted diffs are valid syntax. - let diff = chunk.to_string(); - let patch = diffy::Patch::from_str(&diff).unwrap(); - - let Ok(final_content) = diffy::apply(&file.content, &patch) else { - error!("deletion diff did not cleanly apply to file: {path}"); - return Ok(false); - }; - - if !final_content.trim().is_empty() { - error!("deletion diff did not fully delete file contents"); - return Ok(false); + Ok(false) + } else { + Ok(true) } - - Ok(true) } async fn validate_add_file( From 42a0c5dcfe03336724f6c4c5a4a5556fb9fb48b5 Mon Sep 17 00:00:00 2001 From: anastasiia Date: Tue, 7 Nov 2023 10:36:24 -0500 Subject: [PATCH 03/21] add api requests to generate and apply diffs --- client/src/icons/BranchMerged.tsx | 31 ++++ client/src/icons/index.ts | 1 + client/src/locales/en.json | 8 +- client/src/locales/es.json | 8 +- client/src/locales/it.json | 9 +- client/src/locales/ja.json | 8 +- client/src/locales/zh-CN.json | 8 +- .../RightPanel/Conversation/GeneratedDiff.tsx | 49 ++++++ .../RightPanel/Conversation/index.tsx | 158 +++++++++++++----- client/src/services/api.ts | 4 + client/src/utils/prism.ts | 1 + 11 files changed, 234 insertions(+), 51 deletions(-) create mode 100644 client/src/icons/BranchMerged.tsx create mode 100644 client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx diff --git a/client/src/icons/BranchMerged.tsx b/client/src/icons/BranchMerged.tsx new file mode 100644 index 0000000000..7430655497 --- /dev/null +++ b/client/src/icons/BranchMerged.tsx @@ -0,0 +1,31 @@ +import IconWrapper from './Wrapper'; + +const RawIcon = ( + + + +); + +const BoxedIcon = ( + + + +); + +export default IconWrapper(RawIcon, BoxedIcon); diff --git a/client/src/icons/index.ts b/client/src/icons/index.ts index 12c30a2a6c..240d0086d0 100644 --- a/client/src/icons/index.ts +++ b/client/src/icons/index.ts @@ -134,3 +134,4 @@ export { default as Walk } from './Walk'; export { default as Run } from './Run'; export { default as AIAnswerLong } from './AIAnswerLong'; export { default as DocsSection } from './DocsSection'; +export { default as BranchMerged } from './BranchMerged'; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 1bf2d00608..8655618152 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -418,5 +418,9 @@ "Hold <1> to add multiple files": "Hold <1> to add multiple files", "Clear section": "Clear section", "Let’s get you started with bloop!": "Let’s get you started with bloop!", - "Indexing": "Indexing" -} \ No newline at end of file + "Apply changes": "Apply changes", + "Indexing": "Indexing", + "Generated diffs to be applied": "Generated diffs to be applied", + "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.", + "The diff has been applied locally.": "The diff has been applied locally." +} diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 9e03b82223..0029b33d1e 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -419,5 +419,9 @@ "Hold <1> to add multiple files": "Mantenga presionada <1> para agregar múltiples archivos", "Clear section": "Borrar sección", "Let’s get you started with bloop!": "¡Vamos a comenzar con Bloop!", - "Indexing": "Indexación" -} \ No newline at end of file + "Apply changes": "Aplicar cambios", + "Indexing": "Indexación", + "Generated diffs to be applied": "Diferencias generadas a aplicar", + "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "Los siguientes cambios se pueden aplicar a su repositorio. Asegúrese de que las diferencias generadas sean válidas antes de aplicar los cambios.", + "The diff has been applied locally.": "La diferencia se ha aplicado localmente." +} diff --git a/client/src/locales/it.json b/client/src/locales/it.json index 66d241a152..a739e0bd2d 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -397,5 +397,10 @@ "<0><1> to navigate.": "<0><1> per navigare.", "Below are a few questions you can ask me to get started:": "Di seguito alcune domande che puoi pormi per iniziare:", "Check context files for any errors": "Controlla eventuali errori nei file di contesto", - "Indexing": "Indicizzazione" -} \ No newline at end of file + "Please log into your GitHub account to complete setup": "Accedi al tuo account GitHub per completare la configurazione", + "Apply changes": "Applica i cambiamenti", + "Indexing": "Indicizzazione", + "Generated diffs to be applied": "Difficate generate da applicare", + "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "Le seguenti modifiche possono essere applicate al repository. Assicurarsi che le differenze generate siano valide prima di applicare le modifiche.", + "The diff has been applied locally.": "Il diff è stato applicato localmente." +} diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 8096283fc4..4570f2df8d 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -416,5 +416,9 @@ "Hold <1> to add multiple files": "<1> を保持して複数のファイルを追加する", "Clear section": "クリアセクション", "Let’s get you started with bloop!": "Bloopを始めましょう!", - "Indexing": "インデックス付け" -} \ No newline at end of file + "Apply changes": "変更を適用します", + "Indexing": "インデックス付け", + "Generated diffs to be applied": "適用する生成されたdiff", + "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "次の変更をリポジトリに適用できます。 変更を適用する前に、生成されたdiffが有効であることを確認してください。", + "The diff has been applied locally.": "DIFFはローカルで適用されています。" +} diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index a06031d003..087374aa04 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -425,5 +425,9 @@ "Hold <1> to add multiple files": "保持<1> 添加多个文件", "Clear section": "清除部分", "Let’s get you started with bloop!": "让我们开始开始Bloop!", - "Indexing": "索引" -} \ No newline at end of file + "Apply changes": "应用更改", + "Indexing": "索引", + "Generated diffs to be applied": "生成的差异要应用", + "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "以下更改可以应用于您的存储库。 应用更改之前,请确保生成的差异有效。", + "The diff has been applied locally.": "差异已在本地应用。" +} diff --git a/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx b/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx new file mode 100644 index 0000000000..b15252a92a --- /dev/null +++ b/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx @@ -0,0 +1,49 @@ +import { memo, useMemo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { BranchMerged } from '../../../../icons'; +import NewCode from '../../../../components/MarkdownWithCode/NewCode'; + +type Props = { + diff: string; +}; + +const GeneratedDiff = ({ diff }: Props) => { + useTranslation(); + const filePath = useMemo(() => { + const match = diff.match(/^--- (.*)\s/); + return match?.[1]; + }, [diff]); + + const language = useMemo(() => { + if (filePath) { + return filePath.split('.').slice(-1)[0]; + } + }, [filePath]); + + return ( +
+
+
+ + Generated diffs to be applied +
+
+
+

+ + The following changes can be applied to your repository. Make sure + the generated diffs are valid before you apply the changes. + +

+ +
+
+ ); +}; + +export default memo(GeneratedDiff); diff --git a/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx b/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx index 3441786e74..b4319e2403 100644 --- a/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx +++ b/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useContext, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -17,10 +18,14 @@ import { StudioLeftPanelType, } from '../../../../types/general'; import Button from '../../../../components/Button'; -import { ArrowRefresh, TrashCanFilled } from '../../../../icons'; +import { ArrowRefresh, BranchMerged, TrashCanFilled } from '../../../../icons'; import KeyboardChip from '../../KeyboardChip'; import { CodeStudioMessageType, CodeStudioType } from '../../../../types/api'; -import { patchCodeStudio } from '../../../../services/api'; +import { + confirmStudioDiff, + generateStudioDiff, + patchCodeStudio, +} from '../../../../services/api'; import useKeyboardNavigation from '../../../../hooks/useKeyboardNavigation'; import { DeviceContext } from '../../../../context/deviceContext'; import useScrollToBottom from '../../../../hooks/useScrollToBottom'; @@ -28,6 +33,7 @@ import { StudioContext } from '../../../../context/studioContext'; import { PersonalQuotaContext } from '../../../../context/personalQuotaContext'; import { UIContext } from '../../../../context/uiContext'; import ConversationInput from './Input'; +import GeneratedDiff from './GeneratedDiff'; type Props = { setLeftPanel: Dispatch>; @@ -87,6 +93,9 @@ const Conversation = ({ const [inputAuthor, setInputAuthor] = useState( StudioConversationMessageAuthor.USER, ); + const [waitingForDiff, setWaitingForDiff] = useState(false); + const [isDiffApplied, setDiffApplied] = useState(false); + const [diff, setDiff] = useState(''); const inputRef = useRef(null); const setInput = useCallback((value: StudioConversationMessage) => { @@ -193,6 +202,8 @@ const Conversation = ({ setUpgradePopupOpen(true); return; } + setDiffApplied(false); + setDiff(''); setConversation((prev) => [ ...prev, { message: inputValue, author: inputAuthor }, @@ -342,6 +353,36 @@ const Conversation = ({ setConversation([]); }, [studioId]); + const hasCodeBlock = useMemo(() => { + return conversation.some( + (m) => + m.author === StudioConversationMessageAuthor.ASSISTANT && + m.message.includes('```'), + ); + }, [conversation]); + + const handleApplyChanges = useCallback(async () => { + setWaitingForDiff(true); + try { + const resp = await generateStudioDiff(studioId); + setDiff(resp); + } catch (err) { + console.log(err); + } finally { + setWaitingForDiff(false); + } + }, [studioId]); + + const handleConfirmDiff = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + await confirmStudioDiff(studioId, diff); + setDiff(''); + setDiffApplied(true); + }, + [studioId, diff], + ); + const handleKeyEvent = useCallback( (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { @@ -392,6 +433,13 @@ const Conversation = ({ isLast={i === conversation.length - 1} /> ))} + {!!diff && } + {isDiffApplied && ( +
+ + The diff has been applied locally. +
+ )} {!isLoading && !isPreviewing && !( @@ -451,51 +499,79 @@ const Conversation = ({ ) : ( - + ) : ( + <> + + + + ))} + {!diff && ( + + onClick={onSubmit} + > + Generate +
+ + +
+ + )} + )} diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 3960c4da5a..8936ff917d 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -323,6 +323,10 @@ export const importCodeStudio = (thread_id: string, studio_id?: string) => http .post('/studio/import', {}, { params: { thread_id, studio_id } }) .then((r) => r.data); +export const generateStudioDiff = (id: string): Promise => + http(`/studio/${id}/diff`, { timeout: 5 * 60 * 1000 }).then((r) => r.data); +export const confirmStudioDiff = (id: string, diff: string): Promise => + http.post(`/studio/${id}/diff/apply`, diff).then((r) => r.data); export const getFileTokenCount = ( path: string, diff --git a/client/src/utils/prism.ts b/client/src/utils/prism.ts index 7edb059a78..84d81e2735 100644 --- a/client/src/utils/prism.ts +++ b/client/src/utils/prism.ts @@ -36,6 +36,7 @@ import 'prismjs/components/prism-less.min'; import 'prismjs/components/prism-scala.min'; import 'prismjs/components/prism-julia.min'; import 'prismjs/components/prism-docker.min'; +import 'prismjs/components/prism-diff.min'; import type { Token } from '../types/prism'; const newlineRe = /\r\n|\r|\n/; From 7965e9f27cdd5e27289f7e387325c650cb7079f9 Mon Sep 17 00:00:00 2001 From: anastasiia Date: Tue, 7 Nov 2023 10:36:24 -0500 Subject: [PATCH 04/21] add api requests to generate and apply diffs --- .../components/CodeBlock/CodeDiff/index.tsx | 50 +++++++++++++++++++ .../RightPanel/Conversation/GeneratedDiff.tsx | 12 +++-- 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 client/src/components/CodeBlock/CodeDiff/index.tsx diff --git a/client/src/components/CodeBlock/CodeDiff/index.tsx b/client/src/components/CodeBlock/CodeDiff/index.tsx new file mode 100644 index 0000000000..046bec9e4e --- /dev/null +++ b/client/src/components/CodeBlock/CodeDiff/index.tsx @@ -0,0 +1,50 @@ +import FileIcon from '../../FileIcon'; +import { getFileExtensionForLang, getPrettyLangName } from '../../../utils'; +import BreadcrumbsPath from '../../BreadcrumbsPath'; +import CopyButton from '../../MarkdownWithCode/CopyButton'; +import Code from '../Code'; + +type Props = { + code: string; + language: string; + filePath: string; + lineStart: number; +}; + +const CodeDiff = ({ code, language, filePath, lineStart }: Props) => { + return ( +
+
+
+ + {filePath ? ( + + ) : ( + + {getPrettyLangName(language) || language} + + )} +
+ +
+
+ +
+
+ ); +}; + +export default CodeDiff; diff --git a/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx b/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx index b15252a92a..e467fceaf6 100644 --- a/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx +++ b/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx @@ -1,7 +1,7 @@ import { memo, useMemo } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { BranchMerged } from '../../../../icons'; -import NewCode from '../../../../components/MarkdownWithCode/NewCode'; +import CodeDiff from '../../../../components/CodeBlock/CodeDiff'; type Props = { diff: string; @@ -20,6 +20,10 @@ const GeneratedDiff = ({ diff }: Props) => { } }, [filePath]); + const lineStart = useMemo(() => { + return diff.split('\n')[2].match(/@@ -(\d*)/)?.[1]; + }, [diff]); + return (
@@ -35,11 +39,11 @@ const GeneratedDiff = ({ diff }: Props) => { the generated diffs are valid before you apply the changes.

-
From ed6d628b0ec3a7456b3910f3150b8eaa985b51c6 Mon Sep 17 00:00:00 2001 From: anastasiia Date: Tue, 7 Nov 2023 12:21:36 -0500 Subject: [PATCH 05/21] tiny fix --- client/src/components/CodeBlock/CodeDiff/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/CodeBlock/CodeDiff/index.tsx b/client/src/components/CodeBlock/CodeDiff/index.tsx index 046bec9e4e..34dc003d38 100644 --- a/client/src/components/CodeBlock/CodeDiff/index.tsx +++ b/client/src/components/CodeBlock/CodeDiff/index.tsx @@ -34,7 +34,7 @@ const CodeDiff = ({ code, language, filePath, lineStart }: Props) => { -
+
Date: Wed, 8 Nov 2023 06:57:33 -0500 Subject: [PATCH 06/21] adjust APIs, add loading state --- .../components/CodeBlock/CodeDiff/index.tsx | 43 ++++++++++++++----- client/src/locales/en.json | 4 +- client/src/locales/es.json | 4 +- client/src/locales/it.json | 4 +- client/src/locales/ja.json | 4 +- client/src/locales/zh-CN.json | 4 +- .../RightPanel/Conversation/GeneratedDiff.tsx | 33 +++++--------- .../RightPanel/Conversation/index.tsx | 42 +++++++++++++----- client/src/services/api.ts | 8 +++- client/src/types/api.ts | 11 +++++ 10 files changed, 107 insertions(+), 50 deletions(-) diff --git a/client/src/components/CodeBlock/CodeDiff/index.tsx b/client/src/components/CodeBlock/CodeDiff/index.tsx index 34dc003d38..6ae151b6d3 100644 --- a/client/src/components/CodeBlock/CodeDiff/index.tsx +++ b/client/src/components/CodeBlock/CodeDiff/index.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import FileIcon from '../../FileIcon'; import { getFileExtensionForLang, getPrettyLangName } from '../../../utils'; import BreadcrumbsPath from '../../BreadcrumbsPath'; @@ -5,13 +6,15 @@ import CopyButton from '../../MarkdownWithCode/CopyButton'; import Code from '../Code'; type Props = { - code: string; + hunks: { + line_start: number; + patch: string; + }[]; language: string; filePath: string; - lineStart: number; }; -const CodeDiff = ({ code, language, filePath, lineStart }: Props) => { +const CodeDiff = ({ hunks, language, filePath }: Props) => { return (
{ )}
- + h.patch).join('\n')} />
- + {hunks.map((h, index) => ( + <> + + {index !== hunks.length - 1 ? ( +
+                
+                  
+                    
+                      
+                    
+                  
+                
+ .. +
+
+ ) : null} + + ))}
); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 8655618152..aafb610236 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -422,5 +422,7 @@ "Indexing": "Indexing", "Generated diffs to be applied": "Generated diffs to be applied", "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.", - "The diff has been applied locally.": "The diff has been applied locally." + "The diff has been applied locally.": "The diff has been applied locally.", + "Generating diff...": "Generating diff..." } + diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 0029b33d1e..514dbaf0e7 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -423,5 +423,7 @@ "Indexing": "Indexación", "Generated diffs to be applied": "Diferencias generadas a aplicar", "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "Los siguientes cambios se pueden aplicar a su repositorio. Asegúrese de que las diferencias generadas sean válidas antes de aplicar los cambios.", - "The diff has been applied locally.": "La diferencia se ha aplicado localmente." + "The diff has been applied locally.": "La diferencia se ha aplicado localmente.", + "Generating diff...": "Generando diff ..." } + diff --git a/client/src/locales/it.json b/client/src/locales/it.json index a739e0bd2d..20151ff020 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -402,5 +402,7 @@ "Indexing": "Indicizzazione", "Generated diffs to be applied": "Difficate generate da applicare", "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "Le seguenti modifiche possono essere applicate al repository. Assicurarsi che le differenze generate siano valide prima di applicare le modifiche.", - "The diff has been applied locally.": "Il diff è stato applicato localmente." + "The diff has been applied locally.": "Il diff è stato applicato localmente.", + "Generating diff...": "Generare diff ..." } + diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 4570f2df8d..bcfd62dcda 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -420,5 +420,7 @@ "Indexing": "インデックス付け", "Generated diffs to be applied": "適用する生成されたdiff", "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "次の変更をリポジトリに適用できます。 変更を適用する前に、生成されたdiffが有効であることを確認してください。", - "The diff has been applied locally.": "DIFFはローカルで適用されています。" + "The diff has been applied locally.": "DIFFはローカルで適用されています。", + "Generating diff...": "diffを生成します..." } + diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 087374aa04..043c7b879c 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -429,5 +429,7 @@ "Indexing": "索引", "Generated diffs to be applied": "生成的差异要应用", "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "以下更改可以应用于您的存储库。 应用更改之前,请确保生成的差异有效。", - "The diff has been applied locally.": "差异已在本地应用。" + "The diff has been applied locally.": "差异已在本地应用。", + "Generating diff...": "生成差异..." } + diff --git a/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx b/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx index e467fceaf6..9e548f5590 100644 --- a/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx +++ b/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx @@ -1,28 +1,15 @@ -import { memo, useMemo } from 'react'; +import { memo } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { BranchMerged } from '../../../../icons'; import CodeDiff from '../../../../components/CodeBlock/CodeDiff'; +import { GeneratedCodeDiff } from '../../../../types/api'; type Props = { - diff: string; + diff: GeneratedCodeDiff; }; const GeneratedDiff = ({ diff }: Props) => { useTranslation(); - const filePath = useMemo(() => { - const match = diff.match(/^--- (.*)\s/); - return match?.[1]; - }, [diff]); - - const language = useMemo(() => { - if (filePath) { - return filePath.split('.').slice(-1)[0]; - } - }, [filePath]); - - const lineStart = useMemo(() => { - return diff.split('\n')[2].match(/@@ -(\d*)/)?.[1]; - }, [diff]); return (
@@ -39,12 +26,14 @@ const GeneratedDiff = ({ diff }: Props) => { the generated diffs are valid before you apply the changes.

- + {diff.chunks.map((d) => ( + + ))}
); diff --git a/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx b/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx index b4319e2403..0dcc5d39b4 100644 --- a/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx +++ b/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx @@ -20,7 +20,11 @@ import { import Button from '../../../../components/Button'; import { ArrowRefresh, BranchMerged, TrashCanFilled } from '../../../../icons'; import KeyboardChip from '../../KeyboardChip'; -import { CodeStudioMessageType, CodeStudioType } from '../../../../types/api'; +import { + CodeStudioMessageType, + CodeStudioType, + GeneratedCodeDiff, +} from '../../../../types/api'; import { confirmStudioDiff, generateStudioDiff, @@ -32,6 +36,7 @@ import useScrollToBottom from '../../../../hooks/useScrollToBottom'; import { StudioContext } from '../../../../context/studioContext'; import { PersonalQuotaContext } from '../../../../context/personalQuotaContext'; import { UIContext } from '../../../../context/uiContext'; +import LiteLoaderContainer from '../../../../components/Loaders/LiteLoader'; import ConversationInput from './Input'; import GeneratedDiff from './GeneratedDiff'; @@ -95,7 +100,7 @@ const Conversation = ({ ); const [waitingForDiff, setWaitingForDiff] = useState(false); const [isDiffApplied, setDiffApplied] = useState(false); - const [diff, setDiff] = useState(''); + const [diff, setDiff] = useState(null); const inputRef = useRef(null); const setInput = useCallback((value: StudioConversationMessage) => { @@ -203,7 +208,7 @@ const Conversation = ({ return; } setDiffApplied(false); - setDiff(''); + setDiff(null); setConversation((prev) => [ ...prev, { message: inputValue, author: inputAuthor }, @@ -376,8 +381,11 @@ const Conversation = ({ const handleConfirmDiff = useCallback( async (e: React.MouseEvent) => { e.preventDefault(); + if (!diff) { + return; + } await confirmStudioDiff(studioId, diff); - setDiff(''); + setDiff(null); setDiffApplied(true); }, [studioId, diff], @@ -434,14 +442,24 @@ const Conversation = ({ /> ))} {!!diff && } - {isDiffApplied && ( -
- - The diff has been applied locally. + {(isDiffApplied || waitingForDiff) && ( +
+ {waitingForDiff ? ( + + ) : ( + + )} + + {waitingForDiff + ? 'Generating diff...' + : 'The diff has been applied locally.'} +
)} {!isLoading && !isPreviewing && + !waitingForDiff && + !diff && !( conversation[conversation.length - 1]?.author === StudioConversationMessageAuthor.USER @@ -508,14 +526,18 @@ const Conversation = ({ onClick={handleApplyChanges} disabled={waitingForDiff} > - Apply changes + + {waitingForDiff + ? 'Generating diff...' + : 'Apply changes'} + ) : ( <> diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 8936ff917d..caf346d113 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -7,6 +7,7 @@ import { DocSectionType, DocShortType, FileResponse, + GeneratedCodeDiff, HistoryConversationTurn, HoverablesResponse, NLSearchResponse, @@ -323,9 +324,12 @@ export const importCodeStudio = (thread_id: string, studio_id?: string) => http .post('/studio/import', {}, { params: { thread_id, studio_id } }) .then((r) => r.data); -export const generateStudioDiff = (id: string): Promise => +export const generateStudioDiff = (id: string): Promise => http(`/studio/${id}/diff`, { timeout: 5 * 60 * 1000 }).then((r) => r.data); -export const confirmStudioDiff = (id: string, diff: string): Promise => +export const confirmStudioDiff = ( + id: string, + diff: GeneratedCodeDiff, +): Promise => http.post(`/studio/${id}/diff/apply`, diff).then((r) => r.data); export const getFileTokenCount = ( diff --git a/client/src/types/api.ts b/client/src/types/api.ts index 9e562a79f7..77d13f6a93 100644 --- a/client/src/types/api.ts +++ b/client/src/types/api.ts @@ -346,3 +346,14 @@ export type DocSectionType = { section_range: { start: number; end: number }; text: string; }; + +export type GeneratedCodeDiff = { + chunks: { + file: string; + lang: string; + hunks: { + line_start: number; + patch: string; + }[]; + }[]; +}; From 1398257bf045841fe4bb53a4c44d314e3d156f4c Mon Sep 17 00:00:00 2001 From: anastasiia Date: Wed, 8 Nov 2023 07:32:53 -0500 Subject: [PATCH 07/21] add error message --- client/src/locales/en.json | 4 ++- client/src/locales/es.json | 4 ++- client/src/locales/it.json | 4 ++- client/src/locales/ja.json | 4 ++- client/src/locales/zh-CN.json | 4 ++- .../RightPanel/Conversation/Input.tsx | 2 +- .../RightPanel/Conversation/index.tsx | 31 +++++++++++++++---- client/tailwind.config.cjs | 1 + 8 files changed, 42 insertions(+), 12 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index aafb610236..647721fdd6 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -423,6 +423,8 @@ "Generated diffs to be applied": "Generated diffs to be applied", "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.", "The diff has been applied locally.": "The diff has been applied locally.", - "Generating diff...": "Generating diff..." + "Generating diff...": "Generating diff...", + "Diff generation failed": "Diff generation failed" } + diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 514dbaf0e7..ae457a193a 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -424,6 +424,8 @@ "Generated diffs to be applied": "Diferencias generadas a aplicar", "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "Los siguientes cambios se pueden aplicar a su repositorio. Asegúrese de que las diferencias generadas sean válidas antes de aplicar los cambios.", "The diff has been applied locally.": "La diferencia se ha aplicado localmente.", - "Generating diff...": "Generando diff ..." + "Generating diff...": "Generando diff ...", + "Diff generation failed": "Falló la generación de diff" } + diff --git a/client/src/locales/it.json b/client/src/locales/it.json index 20151ff020..0ddd82aa38 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -403,6 +403,8 @@ "Generated diffs to be applied": "Difficate generate da applicare", "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "Le seguenti modifiche possono essere applicate al repository. Assicurarsi che le differenze generate siano valide prima di applicare le modifiche.", "The diff has been applied locally.": "Il diff è stato applicato localmente.", - "Generating diff...": "Generare diff ..." + "Generating diff...": "Generare diff ...", + "Diff generation failed": "Diff generazione non riuscita" } + diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index bcfd62dcda..2d14cf1034 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -421,6 +421,8 @@ "Generated diffs to be applied": "適用する生成されたdiff", "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "次の変更をリポジトリに適用できます。 変更を適用する前に、生成されたdiffが有効であることを確認してください。", "The diff has been applied locally.": "DIFFはローカルで適用されています。", - "Generating diff...": "diffを生成します..." + "Generating diff...": "diffを生成します...", + "Diff generation failed": "DIFF生成は失敗しました" } + diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 043c7b879c..4bfb3f659b 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -430,6 +430,8 @@ "Generated diffs to be applied": "生成的差异要应用", "The following changes can be applied to your repository. Make sure the generated diffs are valid before you apply the changes.": "以下更改可以应用于您的存储库。 应用更改之前,请确保生成的差异有效。", "The diff has been applied locally.": "差异已在本地应用。", - "Generating diff...": "生成差异..." + "Generating diff...": "生成差异...", + "Diff generation failed": "差异生成失败" } + diff --git a/client/src/pages/StudioTab/RightPanel/Conversation/Input.tsx b/client/src/pages/StudioTab/RightPanel/Conversation/Input.tsx index a0429c3e34..4c223d09d5 100644 --- a/client/src/pages/StudioTab/RightPanel/Conversation/Input.tsx +++ b/client/src/pages/StudioTab/RightPanel/Conversation/Input.tsx @@ -222,7 +222,7 @@ const ConversationInput = ({ )}
diff --git a/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx b/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx index 0dcc5d39b4..ea0d048c9b 100644 --- a/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx +++ b/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx @@ -18,7 +18,12 @@ import { StudioLeftPanelType, } from '../../../../types/general'; import Button from '../../../../components/Button'; -import { ArrowRefresh, BranchMerged, TrashCanFilled } from '../../../../icons'; +import { + ArrowRefresh, + BranchMerged, + TrashCanFilled, + WarningSign, +} from '../../../../icons'; import KeyboardChip from '../../KeyboardChip'; import { CodeStudioMessageType, @@ -100,6 +105,7 @@ const Conversation = ({ ); const [waitingForDiff, setWaitingForDiff] = useState(false); const [isDiffApplied, setDiffApplied] = useState(false); + const [isDiffGenFailed, setDiffGenFailed] = useState(false); const [diff, setDiff] = useState(null); const inputRef = useRef(null); @@ -209,6 +215,7 @@ const Conversation = ({ } setDiffApplied(false); setDiff(null); + setDiffGenFailed(false); setConversation((prev) => [ ...prev, { message: inputValue, author: inputAuthor }, @@ -368,11 +375,13 @@ const Conversation = ({ const handleApplyChanges = useCallback(async () => { setWaitingForDiff(true); + setDiffGenFailed(false); try { const resp = await generateStudioDiff(studioId); setDiff(resp); } catch (err) { console.log(err); + setDiffGenFailed(true); } finally { setWaitingForDiff(false); } @@ -442,15 +451,25 @@ const Conversation = ({ /> ))} {!!diff && } - {(isDiffApplied || waitingForDiff) && ( -
- {waitingForDiff ? ( + {(isDiffApplied || waitingForDiff || isDiffGenFailed) && ( +
+ {isDiffGenFailed ? ( + + ) : waitingForDiff ? ( ) : ( )} - {waitingForDiff + {isDiffGenFailed + ? 'Diff generation failed' + : waitingForDiff ? 'Generating diff...' : 'The diff has been applied locally.'} @@ -478,7 +497,7 @@ const Conversation = ({ )}
-
+

{isPreviewing ? (
diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index b2e682b244..4dda9dd35f 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -16,6 +16,7 @@ module.exports = { "bg-border-hover": "rgb(var(--bg-border-hover))", "bg-main": "rgb(var(--bg-main))", "bg-main/8": "rgba(var(--bg-main), 0.08)", + "bg-main/12": "rgba(var(--bg-main), 0.12)", "bg-main/15": "rgba(var(--bg-main), 0.15)", "bg-main/30": "rgba(var(--bg-main), 0.3)", "bg-main-hover": "rgb(var(--bg-main-hover))", From 66ed45a6911893594eee187d8dd6d5017a0543ea Mon Sep 17 00:00:00 2001 From: anastasiia Date: Wed, 8 Nov 2023 08:31:13 -0500 Subject: [PATCH 08/21] fix action buttons after code block is deleted --- client/src/pages/StudioTab/RightPanel/Conversation/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx b/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx index ea0d048c9b..c29138697a 100644 --- a/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx +++ b/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx @@ -537,7 +537,7 @@ const Conversation = ({ ) : ( <> - {hasCodeBlock && + {(hasCodeBlock || diff) && (isDiffApplied ? null : !diff ? (
- ) : ( -
- )} - {showLineNumbers && ( -
( +
+ )) + ) : ( +
- )} + /> + ))}
From 80937e1dd65f4def2d5904d9d23ef1a59c39d197 Mon Sep 17 00:00:00 2001 From: anastasiia Date: Wed, 8 Nov 2023 13:41:44 -0500 Subject: [PATCH 10/21] open diff in left panel with full file --- .../components/CodeBlock/CodeDiff/index.tsx | 33 +++- .../CodeBlock/CodeFull/CodeContainer.tsx | 3 +- .../CodeBlock/CodeFull/CodeContainerFull.tsx | 42 +++- .../CodeFull/CodeContainerVirtualized.tsx | 6 +- .../components/CodeBlock/CodeFull/index.tsx | 13 +- client/src/pages/StudioTab/Content.tsx | 3 + .../src/pages/StudioTab/DiffPanel/index.tsx | 180 ++++++++++++++++++ .../RightPanel/Conversation/GeneratedDiff.tsx | 40 +++- .../RightPanel/Conversation/index.tsx | 2 +- client/src/types/api.ts | 16 +- client/src/types/general.ts | 29 ++- 11 files changed, 329 insertions(+), 38 deletions(-) create mode 100644 client/src/pages/StudioTab/DiffPanel/index.tsx diff --git a/client/src/components/CodeBlock/CodeDiff/index.tsx b/client/src/components/CodeBlock/CodeDiff/index.tsx index 6ae151b6d3..b06a71c0c1 100644 --- a/client/src/components/CodeBlock/CodeDiff/index.tsx +++ b/client/src/components/CodeBlock/CodeDiff/index.tsx @@ -1,23 +1,36 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import FileIcon from '../../FileIcon'; import { getFileExtensionForLang, getPrettyLangName } from '../../../utils'; import BreadcrumbsPath from '../../BreadcrumbsPath'; import CopyButton from '../../MarkdownWithCode/CopyButton'; import Code from '../Code'; +import { DiffChunkType } from '../../../types/general'; -type Props = { - hunks: { - line_start: number; - patch: string; - }[]; +type Props = DiffChunkType & { + onClick: (d: DiffChunkType) => void; language: string; filePath: string; }; -const CodeDiff = ({ hunks, language, filePath }: Props) => { +const CodeDiff = ({ + hunks, + language, + filePath, + onClick, + file, + repo, + branch, + raw_patch, + lang, +}: Props) => { + const handleClick = useCallback(() => { + onClick({ hunks, repo, branch, file, lang, raw_patch }); + }, [hunks, repo, branch, file, lang, raw_patch]); return ( -
{ ))}
-
+ ); }; diff --git a/client/src/components/CodeBlock/CodeFull/CodeContainer.tsx b/client/src/components/CodeBlock/CodeFull/CodeContainer.tsx index 7296cde78d..033c299f8f 100644 --- a/client/src/components/CodeBlock/CodeFull/CodeContainer.tsx +++ b/client/src/components/CodeBlock/CodeFull/CodeContainer.tsx @@ -23,7 +23,7 @@ import { Metadata, BlameLine } from './index'; type Props = { language: string; - metadata: Metadata; + metadata?: Metadata; relativePath: string; repoPath: string; repoName: string; @@ -49,6 +49,7 @@ type Props = { ) => void; width: number; height: number; + isDiff?: boolean; }; const CodeContainer = ({ diff --git a/client/src/components/CodeBlock/CodeFull/CodeContainerFull.tsx b/client/src/components/CodeBlock/CodeFull/CodeContainerFull.tsx index 348ab4ac5a..62058303c8 100644 --- a/client/src/components/CodeBlock/CodeFull/CodeContainerFull.tsx +++ b/client/src/components/CodeBlock/CodeFull/CodeContainerFull.tsx @@ -1,4 +1,4 @@ -import React, { memo, useEffect, useRef, useState } from 'react'; +import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; import CodeLine from '../Code/CodeLine'; import { Token as TokenType } from '../../../types/prism'; import { Range, TokenInfoType, TokenInfoWrapped } from '../../../types/results'; @@ -10,7 +10,7 @@ import { Metadata, BlameLine } from './index'; type Props = { language: string; - metadata: Metadata; + metadata?: Metadata; repoName: string; tokens: TokenType[][]; foldableRanges: Record; @@ -38,6 +38,7 @@ type Props = { | undefined )[]; hoveredLines: [number, number] | null; + isDiff?: boolean; }; const CodeContainerFull = ({ @@ -61,6 +62,7 @@ const CodeContainerFull = ({ relativePath, highlights, hoveredLines, + isDiff, }: Props) => { const ref = useRef(null); const popupRef = useRef(null); @@ -134,6 +136,29 @@ const CodeContainerFull = ({ } }, []); + const lineNumbersAdd = useMemo(() => { + let curr = 0; + return tokens.map((line, i) => { + if (line[0]?.content === '-' || line[1]?.content === '-') { + return null; + } else { + curr++; + return curr; + } + }); + }, [tokens]); + const lineNumbersRemove = useMemo(() => { + let curr = 0; + return tokens.map((line, i) => { + if (line[0]?.content === '+' || line[1]?.content === '+') { + return null; + } else { + curr++; + return curr; + } + }); + }, [tokens]); + return (
{tokens.map((line, index) => { @@ -158,7 +183,7 @@ const CodeContainerFull = ({ showLineNumbers={true} lineHidden={!!foldedLines[index]} blameLine={blameLines[index]} - blame={!!metadata.blame?.length} + blame={!!metadata?.blame?.length} hoverEffect onMouseSelectStart={onMouseSelectStart} onMouseSelectEnd={onMouseSelectEnd} @@ -179,11 +204,20 @@ const CodeContainerFull = ({ index <= hoveredLines[1] } searchTerm={searchTerm} + isNewLine={ + isDiff && (line[0]?.content === '+' || line[1]?.content === '+') + } + isRemovedLine={ + isDiff && (line[0]?.content === '-' || line[1]?.content === '-') + } + lineNumbersDiff={ + isDiff ? [lineNumbersRemove[index], lineNumbersAdd[index]] : null + } > {line.map((token, i) => ( diff --git a/client/src/components/CodeBlock/CodeFull/CodeContainerVirtualized.tsx b/client/src/components/CodeBlock/CodeFull/CodeContainerVirtualized.tsx index a26b31dae3..9adabbe5e1 100644 --- a/client/src/components/CodeBlock/CodeFull/CodeContainerVirtualized.tsx +++ b/client/src/components/CodeBlock/CodeFull/CodeContainerVirtualized.tsx @@ -12,7 +12,7 @@ import { Metadata, BlameLine } from './index'; type Props = { language: string; - metadata: Metadata; + metadata?: Metadata; repoName: string; tokens: TokenType[][]; foldableRanges: Record; @@ -159,7 +159,7 @@ const CodeContainerVirtualized = ({ showLineNumbers={true} lineHidden={!!foldedLines[index]} blameLine={blameLines[index]} - blame={!!metadata.blame?.length} + blame={!!metadata?.blame?.length} hoverEffect onMouseSelectStart={onMouseSelectStart} onMouseSelectEnd={onMouseSelectEnd} @@ -185,7 +185,7 @@ const CodeContainerVirtualized = ({ {tokens[index].map((token, i) => ( getHoverableContent(hr, tr, index) diff --git a/client/src/components/CodeBlock/CodeFull/index.tsx b/client/src/components/CodeBlock/CodeFull/index.tsx index d6dc43109e..0ce3e2bfea 100644 --- a/client/src/components/CodeBlock/CodeFull/index.tsx +++ b/client/src/components/CodeBlock/CodeFull/index.tsx @@ -42,13 +42,14 @@ export interface Metadata { type Props = { code: string; language: string; - metadata: Metadata; + metadata?: Metadata; relativePath: string; repoPath: string; repoName: string; containerWidth: number; containerHeight: number; closePopup?: () => void; + isDiff?: boolean; }; const CodeFull = ({ @@ -61,6 +62,7 @@ const CodeFull = ({ containerWidth, containerHeight, closePopup, + isDiff, }: Props) => { const [foldableRanges, setFoldableRanges] = useState>( {}, @@ -150,7 +152,7 @@ const CodeFull = ({ useEffect(() => { setFoldableRanges( - metadata.lexicalBlocks?.reduce( + metadata?.lexicalBlocks?.reduce( (acc, cur) => ({ ...acc, [cur.start]: cur.end, @@ -158,11 +160,11 @@ const CodeFull = ({ {}, ) || {}, ); - }, [metadata.lexicalBlocks]); + }, [metadata?.lexicalBlocks]); useEffect(() => { const bb: Record = {}; - metadata.blame?.forEach((item) => { + metadata?.blame?.forEach((item) => { bb[item.lineRange.start] = { start: true, commit: item.commit, @@ -173,7 +175,7 @@ const CodeFull = ({ }); setBlameLines(bb); - }, [metadata.blame]); + }, [metadata?.blame]); const toggleBlock = useCallback( (fold: boolean, start: number) => { @@ -395,6 +397,7 @@ const CodeFull = ({ onRefDefClick={onRefDefClick} scrollToIndex={scrollToIndex} highlightColor={highlightColor} + isDiff={isDiff} /> + ) : leftPanel.type === StudioLeftPanelType.DIFF ? ( + ) : null} >; +}; + +const HEADER_HEIGHT = 32; +const SUBHEADER_HEIGHT = 46; +const FOOTER_HEIGHT = 64; +const VERTICAL_PADDINGS = 32; +const HORIZONTAL_PADDINGS = 32; +const BREADCRUMBS_HEIGHT = 41; + +const DiffPanel = ({ hunks, setLeftPanel, branch, filePath, repo }: Props) => { + useTranslation(); + const [file, setFile] = useState(null); + + useEffect(() => { + search( + buildRepoQuery( + repo.ref.startsWith('github.com/') ? repo.ref : repo.name, + filePath, + branch, + ), + ).then((resp) => { + if (resp?.data?.[0]?.kind === 'file') { + setFile(resp?.data?.[0]?.data); + } + }); + }, [filePath, branch, repo]); + + const onBack = useCallback(() => { + setLeftPanel({ type: StudioLeftPanelType.CONTEXT }); + }, [setLeftPanel]); + + const code = useMemo(() => { + if (file?.contents && hunks) { + const result: string[] = []; + const fileLines = file?.contents.split('\n'); + + let prevStart = -1; + (JSON.parse(JSON.stringify(hunks)) as DiffHunkType[]) + .sort((a, b) => b.line_start - a.line_start) + .forEach((h, i, arr) => { + const patchLines = h.patch.split('\n').slice(0, -1); + let patchOffset = 0; + patchLines.forEach((l) => { + if (l.startsWith('+')) { + patchOffset++; + } else if (l.startsWith('-')) { + patchOffset--; + } + }); + result.push( + ...fileLines + .slice( + h.line_start + (patchLines.length - patchOffset), + prevStart, + ) + .reverse(), + ); + result.push(...patchLines.reverse()); + prevStart = h.line_start; + if (i === arr.length - 1 && h.line_start > 0) { + result.push(...fileLines.slice(0, h.line_start).reverse()); + } + }); + + return result.reverse().join('\n'); + } + }, [file?.contents, hunks]); + + useEffect(() => { + if (code && hunks) { + setTimeout(() => { + const firstLine = ( + JSON.parse(JSON.stringify(hunks)) as DiffHunkType[] + ).sort((a, b) => a.line_start - b.line_start)[0].line_start; + if (firstLine) { + const line = findElementInCurrentTab( + `[data-line-number="${firstLine}"]`, + ); + line?.scrollIntoView({ + behavior: 'auto', + block: 'start', + }); + } + }, 500); + } + }, [hunks, code]); + + return ( +
+
+
+
+ +
+

+ {filePath.split('/').pop()} +

+
+
+ +
+
+
+
+ + + + + {!!branch && ( + <> + + + {branch.replace(/^origin\//, '')} + + + )} +
+
+
+ {!!code && file && ( + + )} +
+
+ ); +}; + +export default memo(DiffPanel); diff --git a/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx b/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx index 9e548f5590..839bf2b1a3 100644 --- a/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx +++ b/client/src/pages/StudioTab/RightPanel/Conversation/GeneratedDiff.tsx @@ -1,15 +1,48 @@ -import { memo } from 'react'; +import { + Dispatch, + memo, + SetStateAction, + useCallback, + useContext, + useMemo, +} from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { BranchMerged } from '../../../../icons'; import CodeDiff from '../../../../components/CodeBlock/CodeDiff'; import { GeneratedCodeDiff } from '../../../../types/api'; +import { + DiffChunkType, + StudioLeftPanelDataType, + StudioLeftPanelType, +} from '../../../../types/general'; +import { RepositoriesContext } from '../../../../context/repositoriesContext'; type Props = { diff: GeneratedCodeDiff; + setLeftPanel: Dispatch>; }; -const GeneratedDiff = ({ diff }: Props) => { +const GeneratedDiff = ({ diff, setLeftPanel }: Props) => { useTranslation(); + const { repositories } = useContext(RepositoriesContext); + + const onDiffClick = useCallback( + (chunk: DiffChunkType) => { + const repoFull = repositories?.find((r) => r.ref === chunk.repo); + if (repoFull) { + setLeftPanel({ + type: StudioLeftPanelType.DIFF, + data: { + filePath: chunk.file, + repo: repoFull, + branch: chunk.branch, + hunks: chunk.hunks, + }, + }); + } + }, + [repositories], + ); return (
@@ -31,7 +64,8 @@ const GeneratedDiff = ({ diff }: Props) => { key={d.file} filePath={d.file} language={d.lang || 'diff'} - hunks={d.hunks} + {...d} + onClick={onDiffClick} /> ))}
diff --git a/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx b/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx index c29138697a..c1a8691585 100644 --- a/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx +++ b/client/src/pages/StudioTab/RightPanel/Conversation/index.tsx @@ -450,7 +450,7 @@ const Conversation = ({ isLast={i === conversation.length - 1} /> ))} - {!!diff && } + {!!diff && } {(isDiffApplied || waitingForDiff || isDiffGenFailed) && (
Date: Thu, 9 Nov 2023 06:45:07 -0500 Subject: [PATCH 11/21] allow edit diffs --- .../components/CodeBlock/CodeDiff/index.tsx | 162 +++++++++++++++--- .../MarkdownWithCode/CopyButton.tsx | 20 ++- .../src/pages/StudioTab/DiffPanel/index.tsx | 27 +-- .../RightPanel/Conversation/GeneratedDiff.tsx | 23 +-- .../RightPanel/Conversation/index.tsx | 56 +++++- 5 files changed, 228 insertions(+), 60 deletions(-) diff --git a/client/src/components/CodeBlock/CodeDiff/index.tsx b/client/src/components/CodeBlock/CodeDiff/index.tsx index b06a71c0c1..d9668d9e41 100644 --- a/client/src/components/CodeBlock/CodeDiff/index.tsx +++ b/client/src/components/CodeBlock/CodeDiff/index.tsx @@ -1,15 +1,21 @@ -import React, { useCallback } from 'react'; +import React, { ChangeEvent, useCallback, useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import FileIcon from '../../FileIcon'; import { getFileExtensionForLang, getPrettyLangName } from '../../../utils'; import BreadcrumbsPath from '../../BreadcrumbsPath'; import CopyButton from '../../MarkdownWithCode/CopyButton'; import Code from '../Code'; import { DiffChunkType } from '../../../types/general'; +import Button from '../../Button'; +import { Pen, TrashCanFilled } from '../../../icons'; type Props = DiffChunkType & { onClick: (d: DiffChunkType) => void; language: string; filePath: string; + onDiffRemoved: (i: number) => void; + onDiffChanged: (i: number, p: string) => void; + i: number; }; const CodeDiff = ({ @@ -22,10 +28,61 @@ const CodeDiff = ({ branch, raw_patch, lang, + i, + onDiffChanged, + onDiffRemoved, }: Props) => { + const [isEditMode, setIsEditMode] = useState(false); + const [editedValue, setEditedValue] = useState( + raw_patch.split('\n').slice(2, -1).join('\n'), + ); + const { t } = useTranslation(); + + useEffect(() => { + setEditedValue(raw_patch.split('\n').slice(2, -1).join('\n')); + }, [raw_patch]); + const handleClick = useCallback(() => { onClick({ hunks, repo, branch, file, lang, raw_patch }); }, [hunks, repo, branch, file, lang, raw_patch]); + + const onEditClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setIsEditMode(true); + }, []); + + const onCancelEdit = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setIsEditMode(false); + }, []); + + const handleChange = useCallback((e: ChangeEvent) => { + setEditedValue(e.target.value); + }, []); + + const onSaveEdit = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onDiffChanged( + i, + `--- ${filePath} ++++ ${filePath} +${editedValue} +`, + ); + setIsEditMode(false); + }, + [i, editedValue, onDiffChanged], + ); + + const onRemove = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onDiffRemoved(i); + }, + [i, onDiffRemoved], + ); + return ( )}
- h.patch).join('\n')} /> +
+ {repo.startsWith('github.com/') ? ( + + ) : isEditMode ? ( + <> + + + + ) : ( + <> + + + + )} +
- {hunks.map((h, index) => ( - <> - - {index !== hunks.length - 1 ? ( -
-                
-                  
-                    
-                      
-                    
-                  
-                
- .. -
-
- ) : null} - - ))} + {isEditMode ? ( +