# Objective Closes EP-78 - Describe the objective or issue this PR addresses. - If you're fixing a specific issue, use "Fixes #X" for each issue as [described in the GitHub docs](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword). ## Solution - Describe the solution used to achieve the objective above. ## Testing - Did you test these changes? If so, how? - Are there any parts that need more testing? - How can other people (reviewers) test your changes? Is there anything specific they need to know? - If relevant, what platforms did you test these changes on, and are there any important ones you can't test? ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase > This section is optional. If this PR does not include a visual change or does not add a new user-facing feature, you can delete this section. - Help others understand the result of this PR by showcasing your awesome work! - If this PR includes a visual change, consider adding a screenshot, GIF, or video - A before/after comparison is very useful for changes to existing features! While a showcase should aim to be brief and digestible, you can use a toggleable section to save space on longer showcases: <details> <summary>Click to view showcase</summary> My super cool demos here </details> --- Release Notes: - N/A or Added/Fixed/Improved ...
385 lines
15 KiB
Rust
385 lines
15 KiB
Rust
use crate::{EditPredictionStore, StoredEvent, zeta};
|
|
|
|
use anyhow::{Context as _, Result};
|
|
use buffer_diff::BufferDiffSnapshot;
|
|
use collections::HashMap;
|
|
use gpui::{Context, Entity, Task};
|
|
use language::{Buffer, BufferSnapshot, ToPoint as _};
|
|
use project::{Project, ProjectPath, WorktreeId};
|
|
use std::{fmt::Write as _, ops::Range, path::Path, sync::Arc};
|
|
use text::{OffsetRangeExt as _, Point};
|
|
use util::rel_path::RelPath;
|
|
|
|
const MAX_UNCOMMITTED_DIFF_SIZE: usize = 64 * 1024;
|
|
|
|
pub type UncommittedDiffSnapshot = Vec<(Arc<Path>, BufferSnapshot, BufferDiffSnapshot)>;
|
|
pub type UncommittedDiffResult = std::result::Result<UncommittedDiffSnapshot, Arc<anyhow::Error>>;
|
|
|
|
pub use zeta_prompt::udiff::CURSOR_POSITION_MARKER;
|
|
|
|
pub(crate) struct CapturedPredictionContext {
|
|
pub(crate) repository_url: Option<String>,
|
|
pub(crate) revision: Option<String>,
|
|
pub(crate) uncommitted_diff: Option<String>,
|
|
pub(crate) buffer_diagnostics: Vec<zeta_prompt::ActiveBufferDiagnostic>,
|
|
pub(crate) editable_context: Vec<zeta_prompt::RelatedFile>,
|
|
}
|
|
|
|
pub(crate) fn capture_prediction_context(
|
|
project: Entity<Project>,
|
|
buffer: Entity<Buffer>,
|
|
cursor_anchor: language::Anchor,
|
|
stored_events: Vec<StoredEvent>,
|
|
repository_url: Option<String>,
|
|
revision: Option<String>,
|
|
editable_context_task: Task<Result<Vec<zeta_prompt::RelatedFile>>>,
|
|
cx: &mut Context<EditPredictionStore>,
|
|
) -> Option<Task<Result<CapturedPredictionContext>>> {
|
|
let snapshot = buffer.read(cx).snapshot();
|
|
let worktree_id = snapshot.file()?.worktree_id(cx);
|
|
let uncommitted_diff_task =
|
|
uncommitted_diffs_for_events(project, worktree_id, stored_events, cx);
|
|
|
|
Some(cx.spawn(async move |_this, cx| {
|
|
let uncommitted_diff_snapshot = match uncommitted_diff_task.await {
|
|
Ok(snapshot) => Some(snapshot),
|
|
Err(error) => {
|
|
log::debug!("failed to capture uncommitted diff: {error:?}");
|
|
None
|
|
}
|
|
};
|
|
|
|
let uncommitted_diff = if let Some(uncommitted_diff_snapshot) = uncommitted_diff_snapshot {
|
|
let estimated_uncommitted_diff_size = uncommitted_diff_snapshot
|
|
.iter()
|
|
.map(|(_, buffer_snapshot, diff_snapshot)| {
|
|
diff_snapshot
|
|
.hunks(buffer_snapshot)
|
|
.map(|hunk| {
|
|
hunk.diff_base_byte_range.len()
|
|
+ hunk.range.to_offset(buffer_snapshot).len()
|
|
})
|
|
.sum::<usize>()
|
|
})
|
|
.sum::<usize>();
|
|
|
|
if estimated_uncommitted_diff_size <= MAX_UNCOMMITTED_DIFF_SIZE {
|
|
let uncommitted_diff = cx
|
|
.background_executor()
|
|
.spawn(async move { compute_uncommitted_diff(uncommitted_diff_snapshot) })
|
|
.await;
|
|
(uncommitted_diff.len() <= MAX_UNCOMMITTED_DIFF_SIZE).then_some(uncommitted_diff)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let buffer_diagnostics = zeta::active_buffer_diagnostics(
|
|
&snapshot,
|
|
Point::new(0, 0)..snapshot.max_point(),
|
|
cursor_anchor.to_point(&snapshot).row,
|
|
100,
|
|
);
|
|
let editable_context = match editable_context_task.await {
|
|
Ok(editable_context) => editable_context,
|
|
Err(error) => {
|
|
log::debug!("failed to capture editable context: {error:?}");
|
|
Vec::new()
|
|
}
|
|
};
|
|
|
|
Ok(CapturedPredictionContext {
|
|
repository_url,
|
|
revision,
|
|
uncommitted_diff,
|
|
buffer_diagnostics,
|
|
editable_context,
|
|
})
|
|
}))
|
|
}
|
|
|
|
pub fn uncommitted_diffs_for_events(
|
|
project: Entity<Project>,
|
|
worktree_id: WorktreeId,
|
|
events: Vec<StoredEvent>,
|
|
cx: &Context<'_, EditPredictionStore>,
|
|
) -> Task<UncommittedDiffResult> {
|
|
let git_store = project.read_with(cx, |project, _| project.git_store().clone());
|
|
|
|
cx.spawn(async move |_store, cx| {
|
|
let (worktree_root_name, worktree_abs_path, path_style) = project
|
|
.read_with(cx, |project, cx| {
|
|
let worktree = project.worktree_for_id(worktree_id, cx)?;
|
|
let worktree = worktree.read(cx);
|
|
let path_style = worktree.path_style();
|
|
let root_name = RelPath::new(Path::new(worktree.root_name_str()), path_style)
|
|
.ok()?
|
|
.into_owned();
|
|
Some((root_name, worktree.abs_path(), path_style))
|
|
})
|
|
.context("failed to find worktree for uncommitted diff capture")
|
|
.map_err(Arc::new)?;
|
|
|
|
let events_with_paths = events
|
|
.into_iter()
|
|
.filter_map(|stored_event| {
|
|
let zeta_prompt::Event::BufferChange { path, .. } = stored_event.event.as_ref();
|
|
let path = if let Ok(path) = RelPath::new(path, path_style) {
|
|
path.strip_prefix(&worktree_root_name).ok()?.into_arc()
|
|
} else {
|
|
let path = path.strip_prefix(worktree_abs_path.as_ref()).ok()?;
|
|
RelPath::new(path, path_style).ok()?.into_arc()
|
|
};
|
|
let project_path = ProjectPath { worktree_id, path };
|
|
let relative_path: Arc<Path> = project_path.path.as_std_path().into();
|
|
Some((stored_event, project_path, relative_path))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut snapshots_by_path: HashMap<Arc<Path>, (BufferSnapshot, BufferDiffSnapshot)> =
|
|
HashMap::default();
|
|
for (stored_event, project_path, relative_path) in events_with_paths.iter().rev() {
|
|
if snapshots_by_path.contains_key(relative_path) {
|
|
continue;
|
|
}
|
|
|
|
let buffer = project
|
|
.update(cx, |project, cx| {
|
|
project.open_buffer(project_path.clone(), cx)
|
|
})
|
|
.await
|
|
.context("failed to open buffer for uncommitted diff capture")
|
|
.map_err(Arc::new)?;
|
|
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
|
|
let file_context = stored_event.file_context.clone();
|
|
let cached_diff = file_context
|
|
.as_ref()
|
|
.and_then(|file_context| {
|
|
file_context
|
|
.read_with(cx, |file_context, _| file_context.uncommitted_diff.clone())
|
|
})
|
|
// The cached diff is keyed by path, but its hunk anchors are pinned to a
|
|
// specific buffer. If that buffer was closed and reopened, `open_buffer`
|
|
// hands back a buffer with a new `BufferId`; reusing the stale diff against
|
|
// it would mix anchors from different buffers and panic. Drop the cache in
|
|
// that case so the diff is recomputed for the current buffer.
|
|
.filter(|diff| diff.read_with(cx, |diff, _| diff.buffer_id) == buffer_id);
|
|
let diff = match cached_diff {
|
|
Some(diff) => diff,
|
|
None => {
|
|
let diff = git_store
|
|
.update(cx, |git_store, cx| {
|
|
git_store.open_uncommitted_diff(buffer.clone(), cx)
|
|
})
|
|
.await
|
|
.context("failed to open uncommitted diff for capture")
|
|
.map_err(Arc::new)?;
|
|
if let Some(file_context) = file_context {
|
|
file_context.update(cx, |file_context, _| {
|
|
file_context.uncommitted_diff = Some(diff.clone());
|
|
});
|
|
}
|
|
diff
|
|
}
|
|
};
|
|
|
|
let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
|
|
let diff_snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx));
|
|
snapshots_by_path.insert(relative_path.clone(), (buffer_snapshot, diff_snapshot));
|
|
}
|
|
|
|
let uncommitted_diff_snapshots = snapshots_by_path
|
|
.into_iter()
|
|
.map(|(relative_path, (snapshot, diff_snapshot))| {
|
|
(relative_path, snapshot, diff_snapshot)
|
|
})
|
|
.collect();
|
|
|
|
Ok(uncommitted_diff_snapshots)
|
|
})
|
|
}
|
|
|
|
pub fn compute_cursor_excerpt(
|
|
snapshot: &language::BufferSnapshot,
|
|
cursor_anchor: language::Anchor,
|
|
) -> (String, usize, Range<Point>) {
|
|
use text::ToOffset as _;
|
|
use text::ToPoint as _;
|
|
|
|
let cursor_offset = cursor_anchor.to_offset(snapshot);
|
|
let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) =
|
|
crate::cursor_excerpt::compute_cursor_excerpt(snapshot, cursor_offset);
|
|
let syntax_ranges = crate::cursor_excerpt::compute_syntax_ranges(
|
|
snapshot,
|
|
cursor_offset,
|
|
&excerpt_offset_range,
|
|
);
|
|
let excerpt_text: String = snapshot.text_for_range(excerpt_point_range).collect();
|
|
let (_, context_range) = zeta_prompt::compute_editable_and_context_ranges(
|
|
&excerpt_text,
|
|
cursor_offset_in_excerpt,
|
|
&syntax_ranges,
|
|
100,
|
|
50,
|
|
);
|
|
let context_text = excerpt_text[context_range.clone()].to_string();
|
|
let cursor_in_context = cursor_offset_in_excerpt.saturating_sub(context_range.start);
|
|
let context_buffer_start =
|
|
(excerpt_offset_range.start + context_range.start).to_point(snapshot);
|
|
let context_buffer_end = (excerpt_offset_range.start + context_range.end).to_point(snapshot);
|
|
(
|
|
context_text,
|
|
cursor_in_context,
|
|
context_buffer_start..context_buffer_end,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn compute_uncommitted_diff(snapshot: UncommittedDiffSnapshot) -> String {
|
|
let mut uncommitted_diff = String::new();
|
|
let mut snapshots_by_path = snapshot;
|
|
snapshots_by_path.sort_by(|(left_path, _, _), (right_path, _, _)| left_path.cmp(right_path));
|
|
for (relative_path, buffer_snapshot, diff_snapshot) in snapshots_by_path {
|
|
let base_snapshot = diff_snapshot.base_text();
|
|
let is_existing_file = diff_snapshot.base_text_exists();
|
|
|
|
let new_path_str = relative_path.to_string_lossy();
|
|
let old_path_str = if is_existing_file {
|
|
new_path_str.as_ref()
|
|
} else {
|
|
"/dev/null"
|
|
};
|
|
writeln!(
|
|
uncommitted_diff,
|
|
"--- {}{old_path_str}",
|
|
if is_existing_file { "a/" } else { "" }
|
|
)
|
|
.ok();
|
|
writeln!(uncommitted_diff, "+++ b/{new_path_str}").ok();
|
|
|
|
if !is_existing_file {
|
|
let new_text = buffer_snapshot.text();
|
|
writeln!(
|
|
uncommitted_diff,
|
|
"@@ -0,0 +1,{} @@",
|
|
new_text.lines().count()
|
|
)
|
|
.ok();
|
|
for line in new_text.lines() {
|
|
writeln!(uncommitted_diff, "+{line}").ok();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let mut ranges: Vec<(Range<u32>, Range<u32>)> = Vec::new();
|
|
for hunk in (&diff_snapshot).hunks(&buffer_snapshot) {
|
|
let old_start = base_snapshot
|
|
.offset_to_point(hunk.diff_base_byte_range.start)
|
|
.row;
|
|
let old_end =
|
|
exclusive_end_row(base_snapshot.offset_to_point(hunk.diff_base_byte_range.end));
|
|
let new_start = hunk.range.start.row;
|
|
let new_end = exclusive_end_row(hunk.range.end);
|
|
let old_range = old_start.saturating_sub(3)..old_end + 3;
|
|
let new_range = new_start.saturating_sub(3)..new_end + 3;
|
|
|
|
if let Some((last_old_range, last_new_range)) = ranges.last_mut()
|
|
&& (old_range.start <= last_old_range.end || new_range.start <= last_new_range.end)
|
|
{
|
|
last_old_range.end = last_old_range.end.max(old_range.end);
|
|
last_new_range.end = last_new_range.end.max(new_range.end);
|
|
continue;
|
|
}
|
|
ranges.push((old_range, new_range));
|
|
}
|
|
|
|
for (old_range, new_range) in ranges {
|
|
uncommitted_diff.push_str(&language::unified_diff_with_offsets(
|
|
&base_snapshot
|
|
.text_for_range(
|
|
Point::new(old_range.start, 0)
|
|
..row_start_or_max(base_snapshot, old_range.end),
|
|
)
|
|
.collect::<String>(),
|
|
&buffer_snapshot
|
|
.text_for_range(
|
|
Point::new(new_range.start, 0)
|
|
..row_start_or_max(&buffer_snapshot, new_range.end),
|
|
)
|
|
.collect::<String>(),
|
|
old_range.start,
|
|
new_range.start,
|
|
));
|
|
}
|
|
if !uncommitted_diff.ends_with('\n') {
|
|
uncommitted_diff.push('\n');
|
|
}
|
|
}
|
|
uncommitted_diff
|
|
}
|
|
|
|
fn row_start_or_max(snapshot: &language::BufferSnapshot, row: u32) -> Point {
|
|
if row >= snapshot.max_point().row {
|
|
snapshot.max_point()
|
|
} else {
|
|
Point::new(row, 0)
|
|
}
|
|
}
|
|
|
|
fn exclusive_end_row(point: Point) -> u32 {
|
|
if point.column == 0 {
|
|
point.row
|
|
} else {
|
|
point.row + 1
|
|
}
|
|
}
|
|
|
|
pub fn format_cursor_excerpt(
|
|
excerpt: &str,
|
|
cursor_offset: usize,
|
|
line_comment_prefix: &str,
|
|
) -> String {
|
|
let cursor_line_start = excerpt[..cursor_offset]
|
|
.rfind('\n')
|
|
.map(|pos| pos + 1)
|
|
.unwrap_or(0);
|
|
let cursor_line_end = excerpt[cursor_line_start..]
|
|
.find('\n')
|
|
.map(|pos| cursor_line_start + pos + 1)
|
|
.unwrap_or(excerpt.len());
|
|
let cursor_line = &excerpt[cursor_line_start..cursor_line_end];
|
|
let cursor_line_indent = &cursor_line[..cursor_line.len() - cursor_line.trim_start().len()];
|
|
let cursor_column = cursor_offset - cursor_line_start;
|
|
|
|
let mut marker_line = String::new();
|
|
if cursor_column < line_comment_prefix.len() {
|
|
for _ in 0..cursor_column {
|
|
marker_line.push(' ');
|
|
}
|
|
marker_line.push_str(line_comment_prefix);
|
|
write!(marker_line, " <{}", CURSOR_POSITION_MARKER).unwrap();
|
|
} else {
|
|
if cursor_column >= cursor_line_indent.len() + line_comment_prefix.len() {
|
|
marker_line.push_str(cursor_line_indent);
|
|
}
|
|
marker_line.push_str(line_comment_prefix);
|
|
while marker_line.len() < cursor_column {
|
|
marker_line.push(' ');
|
|
}
|
|
write!(marker_line, "^{}", CURSOR_POSITION_MARKER).unwrap();
|
|
}
|
|
|
|
let mut result = String::with_capacity(excerpt.len() + marker_line.len() + 2);
|
|
result.push_str(&excerpt[..cursor_line_end]);
|
|
if !result.ends_with('\n') {
|
|
result.push('\n');
|
|
}
|
|
result.push_str(&marker_line);
|
|
if cursor_line_end < excerpt.len() {
|
|
result.push('\n');
|
|
result.push_str(&excerpt[cursor_line_end..]);
|
|
}
|
|
result
|
|
}
|