2026-05-24 16:44:10 +01:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
2026-05-31 00:45:25 +01:00
|
|
|
use gpui::{
|
|
|
|
|
AppContext, FocusHandle, InteractiveElement, IntoElement, ParentElement, Styled, div,
|
|
|
|
|
prelude::FluentBuilder,
|
|
|
|
|
};
|
2026-05-28 22:28:59 +01:00
|
|
|
|
2026-05-18 22:30:46 +08:00
|
|
|
use crate::{
|
2026-05-28 22:28:59 +01:00
|
|
|
api::{self},
|
|
|
|
|
app,
|
2026-05-31 00:45:25 +01:00
|
|
|
component::{
|
|
|
|
|
diff_view::{DiffViewContent, DiffViewState, diff_view},
|
|
|
|
|
text_input::{TextInput, text_input},
|
|
|
|
|
},
|
2026-05-25 00:08:22 +01:00
|
|
|
query::{self, QueryStatus, read_query, use_query, watch_query},
|
2026-05-28 22:28:59 +01:00
|
|
|
util,
|
2026-05-18 22:30:46 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pub(crate) struct PullRequestDiffView {
|
|
|
|
|
pr_query: query::Entity<api::issues::FetchPullRequest>,
|
2026-05-23 12:28:45 +01:00
|
|
|
content_diff_query: Option<query::Entity<api::repo::FetchFileDiff>>,
|
2026-05-24 16:44:10 +01:00
|
|
|
diff_view_state: DiffViewState,
|
|
|
|
|
diff_view_content: Option<DiffViewContent>,
|
2026-05-28 22:28:59 +01:00
|
|
|
current_file_path: Option<Arc<str>>,
|
2026-05-31 00:45:25 +01:00
|
|
|
|
|
|
|
|
focus_handle: gpui::FocusHandle,
|
|
|
|
|
is_search_input_visible: bool,
|
|
|
|
|
search_input: gpui::Entity<TextInput>,
|
2026-05-18 22:30:46 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 00:45:25 +01:00
|
|
|
gpui::actions!([Search, Escape]);
|
|
|
|
|
|
|
|
|
|
const KEY_CONTEXT: &'static str = "PullRequestDiffView";
|
|
|
|
|
|
2026-05-18 22:30:46 +08:00
|
|
|
impl PullRequestDiffView {
|
2026-05-28 22:28:59 +01:00
|
|
|
pub(crate) fn new(pr_id: api::issues::Id, cx: &mut gpui::Context<Self>) -> Self {
|
|
|
|
|
let mut s = Self {
|
|
|
|
|
pr_query: use_query(api::issues::FetchPullRequest { id: pr_id }, cx),
|
|
|
|
|
content_diff_query: None,
|
|
|
|
|
diff_view_state: DiffViewState::new(),
|
|
|
|
|
diff_view_content: None,
|
|
|
|
|
current_file_path: None,
|
2026-05-31 00:45:25 +01:00
|
|
|
|
|
|
|
|
focus_handle: cx.focus_handle(),
|
|
|
|
|
is_search_input_visible: false,
|
|
|
|
|
search_input: cx.new(|cx| TextInput::with_placeholder("Search", cx)),
|
2026-05-28 22:28:59 +01:00
|
|
|
};
|
|
|
|
|
s.on_create(cx);
|
|
|
|
|
s
|
|
|
|
|
}
|
2026-05-24 16:44:10 +01:00
|
|
|
|
2026-05-31 00:45:25 +01:00
|
|
|
pub(crate) fn key_bindings() -> [gpui::KeyBinding; 2] {
|
|
|
|
|
[
|
|
|
|
|
gpui::KeyBinding::new("cmd-f", Search, Some(KEY_CONTEXT)),
|
|
|
|
|
gpui::KeyBinding::new("escape", Escape, Some(KEY_CONTEXT)),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 22:28:59 +01:00
|
|
|
pub(crate) fn show_diff_for_file(
|
|
|
|
|
&mut self,
|
|
|
|
|
file_path: &Arc<str>,
|
|
|
|
|
cx: &mut gpui::Context<Self>,
|
|
|
|
|
) {
|
|
|
|
|
self.current_file_path = Some(Arc::clone(file_path));
|
2026-05-18 22:30:46 +08:00
|
|
|
self.start_content_queries(cx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn start_content_queries(&mut self, cx: &mut gpui::Context<Self>) {
|
2026-05-24 16:44:10 +01:00
|
|
|
if self.content_diff_query.is_some() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-28 22:28:59 +01:00
|
|
|
let Some(selected_file_path) = self.current_file_path.as_deref() else {
|
2026-05-24 16:44:10 +01:00
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-23 12:28:45 +01:00
|
|
|
let Some((old_file_ref, new_file_ref)) = ({
|
2026-05-18 22:30:46 +08:00
|
|
|
if let QueryStatus::Loaded(pr) = read_query(&self.pr_query, cx) {
|
|
|
|
|
Some((
|
2026-05-23 12:28:45 +01:00
|
|
|
api::repo::FileRef {
|
2026-05-18 22:30:46 +08:00
|
|
|
repo_slug: pr.base_repo_slug.clone(),
|
2026-05-24 16:44:10 +01:00
|
|
|
path: Arc::from(selected_file_path),
|
2026-05-18 22:30:46 +08:00
|
|
|
reff: Some(pr.base_ref.clone()),
|
|
|
|
|
},
|
2026-05-23 12:28:45 +01:00
|
|
|
api::repo::FileRef {
|
2026-05-18 22:30:46 +08:00
|
|
|
repo_slug: pr.head_repo_slug.clone(),
|
2026-05-24 16:44:10 +01:00
|
|
|
path: Arc::from(selected_file_path),
|
2026-05-18 22:30:46 +08:00
|
|
|
reff: Some(pr.head_ref.clone()),
|
|
|
|
|
},
|
|
|
|
|
))
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-23 12:28:45 +01:00
|
|
|
let content_diff_query = use_query(
|
|
|
|
|
api::repo::FetchFileDiff {
|
|
|
|
|
base: old_file_ref,
|
|
|
|
|
head: new_file_ref,
|
|
|
|
|
},
|
|
|
|
|
cx,
|
|
|
|
|
);
|
2026-05-25 00:08:22 +01:00
|
|
|
_ = watch_query(&content_diff_query, Self::sync_content_diff_query, cx).detach();
|
2026-05-24 16:44:10 +01:00
|
|
|
|
2026-05-23 12:28:45 +01:00
|
|
|
self.content_diff_query = Some(content_diff_query);
|
2026-05-18 22:30:46 +08:00
|
|
|
}
|
2026-05-25 00:08:22 +01:00
|
|
|
|
2026-05-28 22:28:59 +01:00
|
|
|
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
|
|
|
|
|
_ = cx
|
|
|
|
|
.observe(&self.pr_query, |this, _, cx| {
|
|
|
|
|
this.start_content_queries(cx);
|
|
|
|
|
})
|
|
|
|
|
.detach();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 00:08:22 +01:00
|
|
|
fn sync_content_diff_query(
|
|
|
|
|
&mut self,
|
|
|
|
|
query: &query::Entity<api::repo::FetchFileDiff>,
|
|
|
|
|
cx: &mut gpui::Context<Self>,
|
|
|
|
|
) {
|
|
|
|
|
if let Some(diff) = {
|
|
|
|
|
match read_query(query, cx) {
|
2026-05-28 22:28:59 +01:00
|
|
|
| QueryStatus::Loaded(diff) => Some(Arc::clone(diff)),
|
|
|
|
|
| _ => None,
|
2026-05-25 00:08:22 +01:00
|
|
|
}
|
|
|
|
|
} {
|
|
|
|
|
self.load_diff_view(diff, cx);
|
|
|
|
|
cx.notify();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_diff_view(
|
|
|
|
|
&mut self,
|
|
|
|
|
content_diff: Arc<util::diff::ContentDiff>,
|
|
|
|
|
cx: &mut gpui::Context<Self>,
|
|
|
|
|
) {
|
|
|
|
|
let theme = app::current_theme(cx);
|
|
|
|
|
let old_content = content_diff.old_content.clone();
|
|
|
|
|
let new_content = content_diff.new_content.clone();
|
|
|
|
|
|
|
|
|
|
self.diff_view_state.reset(content_diff.len());
|
|
|
|
|
self.diff_view_content = Some(content_diff.into());
|
|
|
|
|
|
|
|
|
|
let theme_syntax = theme.syntax;
|
|
|
|
|
|
2026-05-28 22:28:59 +01:00
|
|
|
if let Some(path) = &self.current_file_path {
|
2026-05-25 00:08:22 +01:00
|
|
|
let path = Arc::clone(&path);
|
|
|
|
|
let file_type = util::file::file_type_from_path(&path);
|
|
|
|
|
|
|
|
|
|
let t1 = cx.background_spawn(async move {
|
|
|
|
|
util::syntax_highlight::highlight_content(old_content, file_type, &theme_syntax)
|
|
|
|
|
});
|
|
|
|
|
let t2 = cx.background_spawn(async move {
|
|
|
|
|
util::syntax_highlight::highlight_content(new_content, file_type, &theme_syntax)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_ = cx
|
|
|
|
|
.spawn(async move |weak, cx| match tokio::join!(t1, t2) {
|
2026-05-28 22:28:59 +01:00
|
|
|
| (Some(old_side_highlights), Some(new_side_highlights)) => {
|
|
|
|
|
_ = weak.update(cx, |this, cx| {
|
|
|
|
|
this.diff_view_state
|
|
|
|
|
.set_old_side_highlights(old_side_highlights);
|
|
|
|
|
this.diff_view_state
|
|
|
|
|
.set_new_side_highlights(new_side_highlights);
|
|
|
|
|
cx.notify();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
| _ => {}
|
2026-05-25 00:08:22 +01:00
|
|
|
})
|
|
|
|
|
.detach();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-31 00:45:25 +01:00
|
|
|
|
|
|
|
|
fn open_search_box(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) {
|
|
|
|
|
self.is_search_input_visible = true;
|
|
|
|
|
cx.focus_view(&self.search_input, window);
|
|
|
|
|
cx.notify();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn close_search_box(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) {
|
|
|
|
|
self.is_search_input_visible = false;
|
|
|
|
|
self.focus_handle.focus(window);
|
|
|
|
|
cx.notify();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl gpui::Focusable for PullRequestDiffView {
|
|
|
|
|
fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
|
|
|
|
|
self.focus_handle.clone()
|
|
|
|
|
}
|
2026-05-18 22:30:46 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl gpui::Render for PullRequestDiffView {
|
|
|
|
|
fn render(
|
|
|
|
|
&mut self,
|
2026-05-23 12:28:45 +01:00
|
|
|
_window: &mut gpui::Window,
|
2026-05-28 22:28:59 +01:00
|
|
|
cx: &mut gpui::prelude::Context<Self>,
|
2026-05-18 22:30:46 +08:00
|
|
|
) -> impl gpui::IntoElement {
|
2026-05-31 00:45:25 +01:00
|
|
|
let theme = app::current_theme(cx);
|
2026-05-24 16:44:10 +01:00
|
|
|
let content_diff = self
|
|
|
|
|
.content_diff_query
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|q| read_query(q, cx))
|
|
|
|
|
.unwrap_or(QueryStatus::Loading);
|
|
|
|
|
|
2026-05-31 00:45:25 +01:00
|
|
|
div()
|
|
|
|
|
.id(KEY_CONTEXT)
|
|
|
|
|
.key_context(KEY_CONTEXT)
|
|
|
|
|
.track_focus(&self.focus_handle)
|
|
|
|
|
.flex()
|
|
|
|
|
.flex_col()
|
|
|
|
|
.size_full()
|
|
|
|
|
.when(self.is_search_input_visible, |it| {
|
|
|
|
|
it.child(
|
|
|
|
|
text_input(self.search_input.clone())
|
|
|
|
|
.w_full()
|
|
|
|
|
.text_color(theme.colors.text)
|
|
|
|
|
.text_xs()
|
|
|
|
|
.px_3()
|
|
|
|
|
.bg(theme.colors.surface_chrome)
|
|
|
|
|
.border_0()
|
|
|
|
|
.border_b_1()
|
|
|
|
|
.border_color(theme.colors.border_muted),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.when_some(
|
|
|
|
|
match (content_diff, &self.diff_view_content) {
|
|
|
|
|
| (QueryStatus::Loaded(_), Some(content)) => Some(content.clone()),
|
|
|
|
|
| (_, _) => None,
|
|
|
|
|
},
|
|
|
|
|
|it, content| {
|
|
|
|
|
it.child(
|
|
|
|
|
diff_view(self.diff_view_state.clone(), content.clone()).into_any_element(),
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.on_action(cx.listener(|this, _: &Search, window, cx| {
|
|
|
|
|
this.open_search_box(window, cx);
|
|
|
|
|
}))
|
|
|
|
|
.on_action(cx.listener(|this, _: &Escape, window, cx| {
|
|
|
|
|
this.close_search_box(window, cx);
|
|
|
|
|
}))
|
2026-05-18 22:30:46 +08:00
|
|
|
}
|
|
|
|
|
}
|