Compare commits

..

1 Commits

Author SHA1 Message Date
Michael Benfield
30919d8187 tool use conversion to streaming in progress
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
2025-12-09 16:11:37 -08:00
20 changed files with 448 additions and 2000 deletions

24
Cargo.lock generated
View File

@@ -6997,28 +6997,6 @@ dependencies = [
"url",
]
[[package]]
name = "git_graph"
version = "0.1.0"
dependencies = [
"anyhow",
"collections",
"db",
"git",
"git_ui",
"gpui",
"menu",
"project",
"settings",
"smallvec",
"theme",
"time",
"ui",
"ui_input",
"util",
"workspace",
]
[[package]]
name = "git_hosting_providers"
version = "0.1.0"
@@ -7030,7 +7008,6 @@ dependencies = [
"gpui",
"http_client",
"indoc",
"itertools 0.14.0",
"pretty_assertions",
"regex",
"serde",
@@ -20537,7 +20514,6 @@ dependencies = [
"fs",
"futures 0.3.31",
"git",
"git_graph",
"git_hosting_providers",
"git_ui",
"go_to_line",

View File

@@ -75,7 +75,6 @@ members = [
"crates/fsevent",
"crates/fuzzy",
"crates/git",
"crates/git_graph",
"crates/git_hosting_providers",
"crates/git_ui",
"crates/go_to_line",
@@ -300,7 +299,6 @@ fs = { path = "crates/fs" }
fsevent = { path = "crates/fsevent" }
fuzzy = { path = "crates/fuzzy" }
git = { path = "crates/git" }
git_graph = { path = "crates/git_graph" }
git_hosting_providers = { path = "crates/git_hosting_providers" }
git_ui = { path = "crates/git_ui" }
go_to_line = { path = "crates/go_to_line" }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
use crate::{context::LoadedContext, inline_prompt_editor::CodegenStatus};
use agent_settings::AgentSettings;
use anyhow::{Context as _, Result};
use anyhow::anyhow;
use client::telemetry::Telemetry;
use cloud_llm_client::CompletionIntent;
use collections::HashSet;
@@ -11,12 +13,14 @@ use futures::{
channel::mpsc,
future::{LocalBoxFuture, Shared},
join,
stream::BoxStream,
};
use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task};
use language::{Buffer, IndentKind, Point, TransactionId, line_diff};
use language_model::{
LanguageModel, LanguageModelCompletionError, LanguageModelRegistry, LanguageModelRequest,
LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelTextStream, Role,
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
LanguageModelRequestTool, LanguageModelTextStream, LanguageModelToolUse, Role, TokenUsage,
report_assistant_event,
};
use multi_buffer::MultiBufferRow;
@@ -390,9 +394,15 @@ impl CodegenAlternative {
if cx.has_flag::<InlineAssistantV2FeatureFlag>() {
let request = self.build_request(&model, user_prompt, context_task, cx)?;
let tool_use =
cx.spawn(async move |_, cx| model.stream_completion_tool(request.await, cx).await);
self.handle_tool_use(telemetry_id, provider_id.to_string(), api_key, tool_use, cx);
let completion_events =
cx.spawn(async move |_, cx| model.stream_completion(request.await, cx).await);
self.generation = self.handle_completion(
telemetry_id,
provider_id.to_string(),
api_key,
completion_events,
cx,
);
} else {
let stream: LocalBoxFuture<Result<LanguageModelTextStream>> =
if user_prompt.trim().to_lowercase() == "delete" {
@@ -404,7 +414,8 @@ impl CodegenAlternative {
})
.boxed_local()
};
self.handle_stream(telemetry_id, provider_id.to_string(), api_key, stream, cx);
self.generation =
self.handle_stream(telemetry_id, provider_id.to_string(), api_key, stream, cx);
}
Ok(())
@@ -586,6 +597,21 @@ impl CodegenAlternative {
}))
}
// stream: impl Future<Output = Result<InlineAssistantStream>>
// impl Stream for InlineAssistantStream {
// type Output = InlineAssistantChunk
// }
//
// enum InlineAssistantChunk {
// rewrite_text(String)
// Error(Err)
// }
// explanation_text(String)
//
//
//
// handle_completion_stream
pub fn handle_stream(
&mut self,
model_telemetry_id: String,
@@ -593,7 +619,7 @@ impl CodegenAlternative {
model_api_key: Option<String>,
stream: impl 'static + Future<Output = Result<LanguageModelTextStream>>,
cx: &mut Context<Self>,
) {
) -> Task<()> {
let start_time = Instant::now();
// Make a new snapshot and re-resolve anchor in case the document was modified.
@@ -647,7 +673,8 @@ impl CodegenAlternative {
let completion = Arc::new(Mutex::new(String::new()));
let completion_clone = completion.clone();
self.generation = cx.spawn(async move |codegen, cx| {
cx.notify();
cx.spawn(async move |codegen, cx| {
let stream = stream.await;
let token_usage = stream
@@ -673,6 +700,42 @@ impl CodegenAlternative {
stream?.stream.map_err(|error| error.into()),
);
futures::pin_mut!(chunks);
// impl Stream<Output = Result<String>>;
// struct StreamingDiffLoop {
// diff: StreamingDiff,
// line_diff: LineDiff,
// new_text: String,
// base_indent: Option<usize>,
// line_indent: Option<usize>,
// first_line: bool,
// }
// impl StreamingDiffLoop {
// fn new(selected_text: &str) -> Self {
// Self {
// diff: StreamingDiff::new(selected_text.to_string()),
// line_diff: LineDiff::default(),
// new_text: String::new(),
// base_indent: None,
// line_indent: None,
// first_line: true,
// }
// }
// }
// let diff_loop = StreamingDiffLoop::new(selected_text.to_string());
// while let Some(chunk) = chunks.next().await {
// if response_latency.is_none() {
// response_latency = Some(request_start.elapsed());
// }
// let chunk = chunk?;
// completion_clone.lock().push_str(&chunk);
// diff_loop.push(chunk, suggested_line_indent, selection_start, selected_text, diff_tx);
// }
let mut diff = StreamingDiff::new(selected_text.to_string());
let mut line_diff = LineDiff::default();
@@ -864,8 +927,7 @@ impl CodegenAlternative {
cx.notify();
})
.ok();
});
cx.notify();
})
}
pub fn stop(&mut self, cx: &mut Context<Self>) {
@@ -1040,21 +1102,29 @@ impl CodegenAlternative {
})
}
fn handle_tool_use(
fn handle_completion(
&mut self,
_telemetry_id: String,
_provider_id: String,
_api_key: Option<String>,
tool_use: impl 'static
+ Future<
Output = Result<language_model::LanguageModelToolUse, LanguageModelCompletionError>,
telemetry_id: String,
provider_id: String,
api_key: Option<String>,
completion_stream: Task<
Result<
BoxStream<
'static,
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
>,
LanguageModelCompletionError,
>,
>,
cx: &mut Context<Self>,
) {
) -> Task<()> {
self.diff = Diff::default();
self.status = CodegenStatus::Pending;
self.generation = cx.spawn(async move |codegen, cx| {
cx.notify();
// Leaving this in generation so that STOP equivalent events are respected even
// while we're still pre-processing the completion event
cx.spawn(async move |codegen, cx| {
let finish_with_status = |status: CodegenStatus, cx: &mut AsyncApp| {
let _ = codegen.update(cx, |this, cx| {
this.status = status;
@@ -1063,76 +1133,130 @@ impl CodegenAlternative {
});
};
let tool_use = tool_use.await;
let mut completion_events = match completion_stream.await {
Ok(events) => events,
Err(err) => {
finish_with_status(CodegenStatus::Error(err.into()), cx);
return;
}
};
match tool_use {
Ok(tool_use) if tool_use.name.as_ref() == "rewrite_section" => {
// Parse the input JSON into RewriteSectionInput
match serde_json::from_value::<RewriteSectionInput>(tool_use.input) {
Ok(input) => {
// Store the description if non-empty
let description = if !input.description.trim().is_empty() {
Some(input.description.clone())
} else {
None
};
let chars_read_so_far = Arc::new(Mutex::new(0usize));
let tool_to_text = move |tool_use: LanguageModelToolUse| -> String {
let mut chars_read_so_far = chars_read_so_far.lock();
dbg!(&tool_use);
let input: RewriteSectionInput =
serde_json::from_value(tool_use.input.clone()).unwrap();
let value = input.replacement_text[*chars_read_so_far..].to_string();
*chars_read_so_far = value.len();
value
};
// Apply the replacement text to the buffer and compute diff
let batch_diff_task = codegen
.update(cx, |this, cx| {
this.model_explanation = description.map(Into::into);
let range = this.range.clone();
this.apply_edits(
std::iter::once((range, input.replacement_text)),
cx,
);
this.reapply_batch_diff(cx)
})
.ok();
let mut message_id = None;
let mut first_text = None;
let last_token_usage = Arc::new(Mutex::new(TokenUsage::default()));
let total_text = Arc::new(Mutex::new(String::new()));
// Wait for the diff computation to complete
if let Some(diff_task) = batch_diff_task {
diff_task.await;
}
finish_with_status(CodegenStatus::Done, cx);
return;
loop {
if let Some(first_event) = completion_events.next().await {
dbg!(&first_event);
match first_event {
Ok(LanguageModelCompletionEvent::StartMessage { message_id: id }) => {
dbg!("AAA 0");
message_id = Some(id);
}
Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
if tool_use.name.as_ref() == "rewrite_section" =>
{
dbg!("AAA 1");
first_text = Some(tool_to_text(tool_use));
break;
}
Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => {
*last_token_usage.lock() = token_usage;
}
Ok(LanguageModelCompletionEvent::Text(text)) => {
let mut lock = total_text.lock();
lock.push_str(&text);
}
Ok(e) => {
log::warn!("Unexpected event: {:?}", e);
break;
}
Err(e) => {
finish_with_status(CodegenStatus::Error(e.into()), cx);
return;
break;
}
}
}
Ok(tool_use) if tool_use.name.as_ref() == "failure_message" => {
// Handle failure message tool use
match serde_json::from_value::<FailureMessageInput>(tool_use.input) {
Ok(input) => {
let _ = codegen.update(cx, |this, _cx| {
// Store the failure message as the tool description
this.model_explanation = Some(input.message.into());
});
finish_with_status(CodegenStatus::Done, cx);
return;
}
Err(e) => {
finish_with_status(CodegenStatus::Error(e.into()), cx);
return;
}
}
}
Ok(_tool_use) => {
// Unexpected tool.
finish_with_status(CodegenStatus::Done, cx);
return;
}
Err(e) => {
finish_with_status(CodegenStatus::Error(e.into()), cx);
return;
}
}
});
cx.notify();
let text = total_text.lock().clone();
dbg!(text);
let Some(first_text) = first_text else {
finish_with_status(
CodegenStatus::Error(anyhow!("Failed to start????").into()),
cx,
);
return;
};
let move_last_token_usage = last_token_usage.clone();
let text_stream = Box::pin(futures::stream::once(async { Ok(first_text) }).chain(
completion_events.filter_map(move |e| {
let tool_to_text = tool_to_text.clone();
let last_token_usage = move_last_token_usage.clone();
let total_text = total_text.clone();
async move {
match e {
Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
if tool_use.name.as_ref() == "rewrite_section" =>
{
Some(Ok(tool_to_text(tool_use)))
}
Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => {
*last_token_usage.lock() = token_usage;
None
}
Ok(LanguageModelCompletionEvent::Text(text)) => {
let mut lock = total_text.lock();
lock.push_str(&text);
None
}
e => {
println!("UNEXPECTED EVENT {:?}", e);
None
}
}
}
}),
));
let language_model_text_stream = LanguageModelTextStream {
message_id: message_id,
stream: text_stream,
last_token_usage,
};
let Some(task) = codegen
.update(cx, move |codegen, cx| {
codegen.handle_stream(
telemetry_id,
provider_id,
api_key,
async { Ok(language_model_text_stream) },
cx,
)
})
.ok()
else {
return;
};
task.await;
})
}
}
@@ -1659,7 +1783,7 @@ mod tests {
) -> mpsc::UnboundedSender<String> {
let (chunks_tx, chunks_rx) = mpsc::unbounded();
codegen.update(cx, |codegen, cx| {
codegen.handle_stream(
codegen.generation = codegen.handle_stream(
String::new(),
String::new(),
None,

View File

@@ -16,4 +16,8 @@ pub struct InlineAssistantV2FeatureFlag;
impl FeatureFlag for InlineAssistantV2FeatureFlag {
const NAME: &'static str = "inline-assistant-v2";
fn enabled_for_staff() -> bool {
true
}
}

View File

@@ -1,33 +0,0 @@
[package]
name = "git_graph"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/git_graph.rs"
[features]
default = []
[dependencies]
anyhow.workspace = true
collections.workspace = true
db.workspace = true
git.workspace = true
git_ui.workspace = true
gpui.workspace = true
menu.workspace = true
project.workspace = true
settings.workspace = true
smallvec.workspace = true
theme.workspace = true
time.workspace = true
ui.workspace = true
ui_input.workspace = true
util.workspace = true
workspace.workspace = true

View File

@@ -1 +0,0 @@
../../LICENSE-GPL

View File

@@ -1,578 +0,0 @@
mod graph;
mod graph_rendering;
use anyhow::Context as _;
use gpui::{
AnyElement, App, ClickEvent, Context, Corner, ElementId, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, ListAlignment, ListState, ParentElement, Pixels, Point, Render,
SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, anchored, deferred,
list, px,
};
use graph_rendering::{accent_colors_count, render_graph_cell};
use project::{
Project,
git_store::{GitStoreEvent, RepositoryEvent},
};
use settings::Settings;
use std::path::PathBuf;
use theme::ThemeSettings;
use ui::{ContextMenu, Tooltip, prelude::*};
use util::ResultExt;
use workspace::{
Workspace,
item::{Item, ItemEvent, SerializableItem},
};
use crate::{
graph::{AllCommitCount, CHUNK_SIZE},
graph_rendering::render_graph,
};
actions!(
git_graph,
[
/// Opens the Git Graph panel.
OpenGitGraph,
/// Opens the commit view for the selected commit.
OpenCommitView,
]
);
pub fn init(cx: &mut App) {
workspace::register_serializable_item::<GitGraph>(cx);
cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
workspace.register_action(|workspace, _: &OpenGitGraph, window, cx| {
let project = workspace.project().clone();
let git_graph = cx.new(|cx| GitGraph::new(project, window, cx));
workspace.add_item_to_active_pane(Box::new(git_graph), None, true, window, cx);
});
})
.detach();
}
pub struct GitGraph {
focus_handle: FocusHandle,
graph: crate::graph::GitGraph,
project: Entity<Project>,
max_lanes: usize,
loading: bool,
error: Option<SharedString>,
_load_task: Option<Task<()>>,
selected_commit: Option<usize>,
expanded_commit: Option<usize>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
work_dir: Option<PathBuf>,
row_height: Pixels,
list_state: ListState,
_subscriptions: Vec<Subscription>,
}
impl GitGraph {
pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
.detach();
let git_store = project.read(cx).git_store().clone();
let git_store_subscription = cx.subscribe(&git_store, |this, _, event, cx| match event {
GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::BranchChanged, true)
| GitStoreEvent::ActiveRepositoryChanged(_) => {
// todo! only call load data from render, we should set a bool here
// todo! We should check that the repo actually has a change that would affect the graph
this.load_data(false, cx);
}
_ => {}
});
let settings = ThemeSettings::get_global(cx);
let font_size = settings.buffer_font_size(cx);
let row_height = font_size + px(10.0);
let list_state = ListState::new(0, ListAlignment::Top, px(500.0));
let accent_colors = cx.theme().accents();
let mut this = GitGraph {
focus_handle,
project,
graph: crate::graph::GitGraph::new(accent_colors_count(accent_colors)),
max_lanes: 0,
loading: true,
error: None,
_load_task: None,
selected_commit: None,
expanded_commit: None,
context_menu: None,
work_dir: None,
row_height,
list_state,
// todo! We can just make this a simple Subscription instead of wrapping it
_subscriptions: vec![git_store_subscription],
};
this.load_data(true, cx);
this
}
fn load_data(&mut self, fetch_chunks: bool, cx: &mut Context<Self>) {
let project = self.project.clone();
self.loading = true;
self.error = None;
let commit_count_loaded = !matches!(self.graph.max_commit_count, AllCommitCount::NotLoaded);
if self._load_task.is_some() {
return;
}
let last_loaded_chunk = if !fetch_chunks {
// When we're refreshing the graph we need to start from the beginning
// so the cached commits don't matter
0
} else {
self.graph.commits.len() / CHUNK_SIZE
};
let first_visible_worktree = project.read_with(cx, |project, cx| {
project
.visible_worktrees(cx)
.next()
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
});
self._load_task = Some(cx.spawn(async move |this: WeakEntity<Self>, cx| {
let Some(worktree_path) = first_visible_worktree
.context("Can't open git graph in Project without visible worktrees")
.ok()
else {
// todo! handle error
return;
};
// todo! don't count commits everytime
let commit_count = if fetch_chunks && commit_count_loaded {
None
} else {
crate::graph::commit_count(&worktree_path).await.ok()
};
let result = crate::graph::load_commits(last_loaded_chunk, worktree_path.clone()).await;
this.update(cx, |this, cx| {
this.loading = false;
match result.map(|commits| (commits, commit_count)) {
Ok((commits, commit_count)) => {
if !fetch_chunks {
this.graph.clear();
}
this.graph.add_commits(commits);
this.max_lanes = this.graph.max_lanes;
this.work_dir = Some(worktree_path);
if let Some(commit_count) = commit_count {
this.graph.max_commit_count = AllCommitCount::Loaded(commit_count);
this.list_state.reset(commit_count);
}
}
Err(e) => {
this.error = Some(format!("{:?}", e).into());
}
};
this._load_task.take();
cx.notify();
})
.log_err();
}));
}
// todo unflatten this function
fn render_list_item(
&mut self,
idx: usize,
_window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let row_height = self.row_height;
// let graph_width = px(16.0) * (self.max_lanes.max(2) as f32) + px(24.0);
// todo! make these widths constant
let graph_width = px(16.0) * (4 as f32) + px(24.0);
let date_width = px(140.0);
let author_width = px(120.0);
let commit_width = px(80.0);
self.render_commit_row(
idx,
row_height,
graph_width,
date_width,
author_width,
commit_width,
cx,
)
}
fn render_commit_row(
&mut self,
idx: usize,
row_height: Pixels,
graph_width: Pixels,
date_width: Pixels,
author_width: Pixels,
commit_width: Pixels,
cx: &mut Context<Self>,
) -> AnyElement {
if (idx + CHUNK_SIZE).min(self.graph.max_commit_count.count()) > self.graph.commits.len() {
self.load_data(true, cx);
}
let Some(commit) = self.graph.commits.get(idx) else {
// todo! loading row element
return div().h(row_height).into_any_element();
};
let subject: SharedString = commit.data.subject.clone().into();
let author_name: SharedString = commit.data.author_name.clone().into();
let short_sha: SharedString = commit.data.sha.display_short().into();
let formatted_time: SharedString = commit.data.commit_timestamp.clone().into();
let lane = commit.lane;
let lines = commit.lines.clone();
let color_idx = commit.color_idx;
let is_selected = self.expanded_commit == Some(idx);
let bg = if is_selected {
cx.theme().colors().ghost_element_selected
} else {
cx.theme().colors().editor_background
};
let hover_bg = cx.theme().colors().ghost_element_hover;
h_flex()
.id(ElementId::NamedInteger("commit-row".into(), idx as u64))
.w_full()
.size_full()
.px_2()
.gap_4()
.h(row_height)
.min_h(row_height)
.flex_shrink_0()
.bg(bg)
.hover(move |style| style.bg(hover_bg))
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, _cx| {
this.selected_commit = Some(idx);
}))
.child(
div()
.w(graph_width)
.h_full()
.flex_shrink_0()
.child(render_graph_cell(
lane,
lines,
color_idx,
row_height,
graph_width,
// todo! Make this owned by self so we don't have to allocate every frame
cx.theme().accents().clone(),
)),
)
.child(
h_flex()
.flex_1()
.min_w(px(0.0))
.gap_2()
.overflow_hidden()
.items_center()
.child(
div()
.id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
.flex_1()
.min_w(px(0.0))
.overflow_hidden()
.tooltip(Tooltip::text(subject.clone()))
.child(Label::new(subject).single_line()),
),
)
.child(
div()
.w(date_width)
.flex_shrink_0()
.overflow_hidden()
.child(Label::new(formatted_time).color(Color::Muted).single_line()),
)
.child(
div()
.w(author_width)
.flex_shrink_0()
.overflow_hidden()
.child(Label::new(author_name).color(Color::Muted).single_line()),
)
.child(
div()
.w(commit_width)
.flex_shrink_0()
.child(Label::new(short_sha).color(Color::Accent).single_line()),
)
.debug()
.into_any_element()
}
}
impl Render for GitGraph {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let graph_width = px(16.0) * (4 as f32) + px(24.0);
let date_width = px(140.0);
let author_width = px(120.0);
let commit_width = px(80.0);
let error_banner = self.error.as_ref().map(|error| {
h_flex()
.id("error-banner")
.w_full()
.px_2()
.py_1()
.bg(cx.theme().colors().surface_background)
.border_b_1()
.border_color(cx.theme().colors().border)
.justify_between()
.items_center()
.child(
h_flex()
.gap_2()
.overflow_hidden()
.child(Icon::new(IconName::Warning).color(Color::Error))
.child(Label::new(error.clone()).color(Color::Error).single_line()),
)
.child(
IconButton::new("dismiss-error", IconName::Close)
.icon_size(IconSize::Small)
.on_click(cx.listener(|this, _, _, cx| {
this.error = None;
cx.notify();
})),
)
});
let content = if self.loading && self.graph.commits.is_empty() && false {
let message = if self.loading {
"Loading commits..."
} else {
"No commits found"
};
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(Label::new(message).color(Color::Muted))
} else {
div()
.size_full()
.flex()
.flex_col()
.child(
h_flex()
.w_full()
.px_2()
.py_1()
.gap_4()
.border_b_1()
.border_color(cx.theme().colors().border)
.flex_shrink_0()
.child(
div()
.w(graph_width)
.child(Label::new("Graph").color(Color::Muted)),
)
.child(
div()
.flex_1()
.child(Label::new("Description").color(Color::Muted)),
)
.child(
div()
.w(date_width)
.child(Label::new("Date").color(Color::Muted)),
)
.child(
div()
.w(author_width)
.child(Label::new("Author").color(Color::Muted)),
)
.child(
div()
.w(commit_width)
.child(Label::new("Commit").color(Color::Muted)),
),
)
.child(
h_flex()
.flex_1()
.size_full()
.child(div().h_full().overflow_hidden().child(render_graph(&self)))
.child(
list(
self.list_state.clone(),
cx.processor(Self::render_list_item),
)
.flex_1()
.h_full()
.w_full(),
),
)
};
div()
.size_full()
.bg(cx.theme().colors().editor_background)
.key_context("GitGraph")
.track_focus(&self.focus_handle)
.child(v_flex().size_full().children(error_banner).child(content))
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
deferred(
anchored()
.position(*position)
.anchor(Corner::TopLeft)
.child(menu.clone()),
)
.with_priority(1)
}))
}
}
impl EventEmitter<ItemEvent> for GitGraph {}
impl Focusable for GitGraph {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Item for GitGraph {
type Event = ItemEvent;
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
"Git Graph".into()
}
fn show_toolbar(&self) -> bool {
false
}
fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
f(*event)
}
}
impl SerializableItem for GitGraph {
fn serialized_item_kind() -> &'static str {
"GitGraph"
}
fn cleanup(
workspace_id: workspace::WorkspaceId,
alive_items: Vec<workspace::ItemId>,
_window: &mut Window,
cx: &mut App,
) -> Task<gpui::Result<()>> {
workspace::delete_unloaded_items(
alive_items,
workspace_id,
"git_graphs",
&persistence::GIT_GRAPHS,
cx,
)
}
fn deserialize(
project: Entity<Project>,
_: WeakEntity<Workspace>,
workspace_id: workspace::WorkspaceId,
item_id: workspace::ItemId,
window: &mut Window,
cx: &mut App,
) -> Task<gpui::Result<Entity<Self>>> {
if persistence::GIT_GRAPHS
.get_git_graph(item_id, workspace_id)
.ok()
.is_some_and(|is_open| is_open)
{
let git_graph = cx.new(|cx| GitGraph::new(project, window, cx));
Task::ready(Ok(git_graph))
} else {
Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")))
}
}
fn serialize(
&mut self,
workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<gpui::Result<()>>> {
let workspace_id = workspace.database_id()?;
Some(cx.background_spawn(async move {
persistence::GIT_GRAPHS
.save_git_graph(item_id, workspace_id, true)
.await
}))
}
fn should_serialize(&self, event: &Self::Event) -> bool {
event == &ItemEvent::UpdateTab
}
}
mod persistence {
use db::{
query,
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use workspace::WorkspaceDb;
pub struct GitGraphsDb(ThreadSafeConnection);
impl Domain for GitGraphsDb {
const NAME: &str = stringify!(GitGraphsDb);
const MIGRATIONS: &[&str] = (&[sql!(
CREATE TABLE git_graphs (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
is_open INTEGER DEFAULT FALSE,
PRIMARY KEY(workspace_id, item_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
)]);
}
db::static_connection!(GIT_GRAPHS, GitGraphsDb, [WorkspaceDb]);
impl GitGraphsDb {
query! {
pub async fn save_git_graph(
item_id: workspace::ItemId,
workspace_id: workspace::WorkspaceId,
is_open: bool
) -> Result<()> {
INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, is_open)
VALUES (?, ?, ?)
}
}
query! {
pub fn get_git_graph(
item_id: workspace::ItemId,
workspace_id: workspace::WorkspaceId
) -> Result<bool> {
SELECT is_open
FROM git_graphs
WHERE item_id = ? AND workspace_id = ?
}
}
}
}

View File

@@ -1,363 +0,0 @@
use std::{path::PathBuf, rc::Rc, str::FromStr};
use anyhow::Result;
use collections::HashMap;
use git::Oid;
use gpui::SharedString;
use smallvec::SmallVec;
use time::{OffsetDateTime, UtcOffset};
use util::command::new_smol_command;
/// %H - Full commit hash
/// %aN - Author name
/// %aE - Author email
/// %at - Author timestamp
/// %ct - Commit timestamp
/// %s - Commit summary
/// %P - Parent hashes
/// %D - Ref names
/// %x1E - ASCII record separator, used to split up commit data
static COMMIT_FORMAT: &str = "--format=%H%x1E%aN%x1E%aE%x1E%at%x1E%ct%x1E%s%x1E%P%x1E%D%x1E";
pub(crate) const CHUNK_SIZE: usize = 1000;
pub fn format_timestamp(timestamp: i64) -> String {
let Ok(datetime) = OffsetDateTime::from_unix_timestamp(timestamp) else {
return "Unknown".to_string();
};
let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
let local_datetime = datetime.to_offset(local_offset);
// todo! do we have to parse this function every time?
let format = time::format_description::parse("[day] [month repr:short] [year] [hour]:[minute]")
.unwrap_or_default();
local_datetime.format(&format).unwrap_or_default()
}
// todo! change to repo path
// move to repo as well
pub async fn commit_count(worktree_path: &PathBuf) -> Result<usize> {
let git_log_output = new_smol_command("git")
.current_dir(worktree_path)
.arg("rev-list")
.arg("--all")
.arg("--count")
.output()
.await?;
let stdout = String::from_utf8_lossy(&git_log_output.stdout);
Ok(stdout.trim().parse::<usize>()?)
}
// todo: This function should be on a background thread, and it should return a chunk of commits at a time
// we should also be able to specify the order
// todo: Make this function work over collab as well
pub async fn load_commits(
chunk_position: usize,
worktree_path: PathBuf, //todo! Change to repo path
) -> Result<Vec<CommitData>> {
let start = chunk_position * CHUNK_SIZE;
let git_log_output = new_smol_command("git")
.current_dir(worktree_path)
.arg("log")
.arg("--all")
.arg(COMMIT_FORMAT)
.arg("--date-order")
.arg(format!("--skip={start}"))
.arg(format!("--max-count={}", CHUNK_SIZE))
.output()
.await?;
let stdout = String::from_utf8_lossy(&git_log_output.stdout);
Ok(stdout
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| {
// todo! clean this up
let parts: Vec<&str> = line.split('\x1E').collect();
let sha = parts.get(0)?;
let author_name = parts.get(1)?;
let author_email = parts.get(2)?;
// todo! do we use the author or the commit timestamp
let _author_timestamp = parts.get(3)?;
let commit_timestamp = parts.get(4)?;
let summary = parts.get(5)?;
let parents = parts
.get(6)?
.split_ascii_whitespace()
.filter_map(|hash| Oid::from_str(hash).ok())
.collect();
Some(CommitData {
author_name: SharedString::new(*author_name),
_author_email: SharedString::new(*author_email),
sha: Oid::from_str(sha).ok()?,
parents,
commit_timestamp: format_timestamp(commit_timestamp.parse().ok()?).into(), //todo!
subject: SharedString::new(*summary), // todo!
_ref_names: parts
.get(7)
.filter(|ref_name| !ref_name.is_empty())
.map(|ref_names| ref_names.split(", ").map(SharedString::new).collect())
.unwrap_or_default(),
})
})
.collect::<Vec<_>>())
}
/// Commit data needed for the graph
#[derive(Debug)]
pub struct CommitData {
pub sha: Oid,
/// Most commits have a single parent, so we use a small vec to avoid allocations
pub parents: smallvec::SmallVec<[Oid; 1]>,
pub author_name: SharedString,
pub _author_email: SharedString,
pub commit_timestamp: SharedString,
pub subject: SharedString,
pub _ref_names: Vec<SharedString>,
}
#[derive(Clone, Debug)]
pub struct GraphLine {
pub from_lane: usize,
pub to_lane: usize,
pub line_type: LineType,
pub color_idx: usize,
pub continues_from_above: bool,
pub ends_at_commit: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub enum LineType {
Straight,
MergeDown,
BranchOut,
}
// todo! On accent colors updating it's len we need to update lane colors to use different indices
#[derive(Copy, Clone)]
struct BranchColor(u8);
enum LaneState {
Empty,
Active { sha: Oid, color: BranchColor },
}
impl LaneState {
fn is_commit(&self, other: &Oid) -> bool {
match self {
LaneState::Empty => false,
LaneState::Active { sha, .. } => sha == other,
}
}
fn is_empty(&self) -> bool {
match self {
LaneState::Empty => true,
LaneState::Active { .. } => false,
}
}
}
#[derive(Debug)]
pub struct CommitEntry {
pub data: CommitData,
pub lane: usize,
pub color_idx: usize,
pub lines: Vec<GraphLine>,
}
type ActiveLaneIdx = usize;
pub(crate) enum AllCommitCount {
NotLoaded,
Loaded(usize),
}
impl AllCommitCount {
pub fn count(&self) -> usize {
match self {
AllCommitCount::NotLoaded => 0,
AllCommitCount::Loaded(count) => *count,
}
}
}
pub struct GitGraph {
lane_states: SmallVec<[LaneState; 8]>,
lane_colors: HashMap<ActiveLaneIdx, BranchColor>,
next_color: BranchColor,
accent_colors_count: usize,
pub commits: Vec<Rc<CommitEntry>>,
pub max_commit_count: AllCommitCount,
pub max_lanes: usize,
}
impl GitGraph {
pub fn new(accent_colors_count: usize) -> Self {
GitGraph {
lane_states: SmallVec::default(),
lane_colors: HashMap::default(),
next_color: BranchColor(0),
accent_colors_count,
commits: Vec::default(),
max_commit_count: AllCommitCount::NotLoaded,
max_lanes: 0,
}
}
pub fn clear(&mut self) {
self.lane_states.clear();
self.lane_colors.clear();
self.next_color = BranchColor(0);
self.commits.clear();
self.max_lanes = 0;
}
fn first_empty_lane_idx(&mut self) -> ActiveLaneIdx {
self.lane_states
.iter()
.position(LaneState::is_empty)
.unwrap_or_else(|| {
self.lane_states.push(LaneState::Empty);
self.lane_states.len() - 1
})
}
fn get_lane_color(&mut self, lane_idx: ActiveLaneIdx) -> BranchColor {
let accent_colors_count = self.accent_colors_count;
*self.lane_colors.entry(lane_idx).or_insert_with(|| {
let color_idx = self.next_color;
self.next_color = BranchColor((self.next_color.0 + 1) % accent_colors_count as u8);
color_idx
})
}
pub(crate) fn add_commits(&mut self, commits: Vec<CommitData>) {
for commit in commits.into_iter() {
let commit_lane = self
.lane_states
.iter()
.position(|lane: &LaneState| lane.is_commit(&commit.sha));
let branch_continued = commit_lane.is_some();
let commit_lane = commit_lane.unwrap_or_else(|| self.first_empty_lane_idx());
let commit_color = self.get_lane_color(commit_lane);
let mut lines = Vec::from_iter(self.lane_states.iter().enumerate().filter_map(
|(idx, lane)| {
match lane {
// todo!: We can probably optimize this by using commit_lane != idx && !was_expected
LaneState::Active { sha, color } if sha != &commit.sha => {
Some(GraphLine {
from_lane: idx,
to_lane: idx,
line_type: LineType::Straight,
color_idx: color.0 as usize, // todo! change this
continues_from_above: true,
ends_at_commit: false,
})
}
_ => None,
}
},
));
self.lane_states[commit_lane] = LaneState::Empty;
if commit.parents.is_empty() && branch_continued {
lines.push(GraphLine {
from_lane: commit_lane,
to_lane: commit_lane,
line_type: LineType::Straight,
color_idx: commit_color.0 as usize,
continues_from_above: true,
ends_at_commit: true,
});
}
commit
.parents
.iter()
.enumerate()
.for_each(|(parent_idx, parent)| {
let parent_lane =
self.lane_states
.iter()
.enumerate()
.find_map(|(lane_idx, lane_state)| match lane_state {
LaneState::Active { sha, color } if sha == parent => {
Some((lane_idx, color))
}
_ => None,
});
if let Some((parent_lane, parent_color)) = parent_lane
&& parent_lane != commit_lane
{
// todo! add comment explaining why this is necessary
if branch_continued {
lines.push(GraphLine {
from_lane: commit_lane,
to_lane: commit_lane,
line_type: LineType::Straight,
// todo! this field should be a byte
color_idx: commit_color.0 as usize,
continues_from_above: true,
ends_at_commit: true,
});
}
lines.push(GraphLine {
from_lane: commit_lane,
to_lane: parent_lane,
line_type: LineType::MergeDown,
color_idx: parent_color.0 as usize,
continues_from_above: false,
ends_at_commit: false,
});
// base commit
} else if parent_idx == 0 {
self.lane_states[commit_lane] = LaneState::Active {
sha: *parent,
color: commit_color,
};
lines.push(GraphLine {
from_lane: commit_lane,
to_lane: commit_lane,
line_type: LineType::Straight,
color_idx: commit_color.0 as usize,
continues_from_above: branch_continued,
ends_at_commit: false,
});
} else {
let parent_lane = self.first_empty_lane_idx();
let parent_color = self.get_lane_color(parent_lane);
lines.push(GraphLine {
from_lane: commit_lane,
to_lane: parent_lane,
line_type: LineType::BranchOut,
color_idx: parent_color.0 as usize,
continues_from_above: false,
ends_at_commit: false,
});
}
});
self.max_lanes = self.max_lanes.max(self.lane_states.len());
self.commits.push(Rc::new(CommitEntry {
data: commit,
lane: commit_lane,
color_idx: commit_color.0 as usize,
lines,
}));
}
}
}

View File

@@ -1,343 +0,0 @@
use gpui::{App, Bounds, Hsla, IntoElement, Pixels, Point, Styled, Window, canvas, px};
use theme::AccentColors;
use ui::ActiveTheme as _;
use crate::{
GitGraph,
graph::{GraphLine, LineType},
};
pub fn accent_colors_count(accents: &AccentColors) -> usize {
accents.0.len()
}
const LANE_WIDTH: Pixels = px(16.0);
const LINE_WIDTH: Pixels = px(1.5);
pub fn render_graph(graph: &GitGraph) -> impl IntoElement {
let top_row = graph.list_state.logical_scroll_top();
let row_height = graph.row_height;
let scroll_offset = top_row.offset_in_item;
let first_visible_row = top_row.item_ix;
let graph_width = px(16.0) * (4 as f32) + px(24.0);
let loaded_commit_count = graph.graph.commits.len();
// todo! Figure out how we can avoid over allocating this data
let rows = graph.graph.commits[first_visible_row.min(loaded_commit_count.saturating_sub(1))
..(first_visible_row + 50).min(loaded_commit_count)]
.to_vec();
canvas(
move |_bounds, _window, _cx| {},
move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
window.paint_layer(bounds, |window| {
let left_padding = px(12.0);
let accent_colors = cx.theme().accents();
for (row_idx, row) in rows.into_iter().enumerate() {
let row_color = accent_colors.color_for_index(row.color_idx as u32);
let row_y_center =
bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
- scroll_offset;
let _row_x_coordinate =
bounds.origin.x + row.lane * LANE_WIDTH + LANE_WIDTH / 2.0;
for line in row.lines.iter() {
let line_color = accent_colors.color_for_index(line.color_idx as u32);
let from_x = bounds.origin.x
+ line.from_lane * LANE_WIDTH
+ LANE_WIDTH / 2.0
+ left_padding;
let to_x = bounds.origin.x
+ line.to_lane * LANE_WIDTH
+ LANE_WIDTH / 2.0
+ left_padding;
match line.line_type {
LineType::Straight => {
let start_y = if line.continues_from_above {
row_y_center - row_height / 2.0
} else {
row_y_center
};
let end_y = if line.ends_at_commit {
row_y_center
} else {
row_y_center + row_height / 2.0
};
draw_straight_line(
window, from_x, start_y, from_x, end_y, LINE_WIDTH, line_color,
);
}
LineType::MergeDown | LineType::BranchOut => {
draw_s_curve(
window,
from_x,
row_y_center,
to_x,
row_y_center + row_height / 2.0,
LINE_WIDTH,
line_color,
);
}
}
}
let commit_x = bounds.origin.x
+ left_padding
+ LANE_WIDTH * row.lane as f32
+ LANE_WIDTH / 2.0;
let dot_radius = px(4.5);
let stroke_width = px(1.5);
// Draw colored outline only (hollow/transparent circle)
draw_circle_outline(
window,
commit_x,
row_y_center,
dot_radius,
stroke_width,
row_color,
);
}
})
},
)
.w(graph_width)
.h_full()
}
pub fn render_graph_cell(
lane: usize,
lines: Vec<GraphLine>,
commit_color_idx: usize,
row_height: Pixels,
graph_width: Pixels,
accent_colors: AccentColors,
) -> impl IntoElement {
canvas(
move |_bounds, _window, _cx| {},
move |bounds: Bounds<Pixels>, _: (), window: &mut Window, _cx: &mut App| {
let accent_colors = &accent_colors;
let lane_width = px(16.0);
let left_padding = px(12.0);
let y_top = bounds.origin.y;
let y_center = bounds.origin.y + row_height / 2.0;
let y_bottom = bounds.origin.y + row_height;
let line_width = px(1.5);
for line in &lines {
let color = accent_colors.color_for_index(line.color_idx as u32);
let from_x = bounds.origin.x
+ left_padding
+ lane_width * line.from_lane as f32
+ lane_width / 2.0;
let to_x = bounds.origin.x
+ left_padding
+ lane_width * line.to_lane as f32
+ lane_width / 2.0;
match line.line_type {
LineType::Straight => {
let start_y = if line.continues_from_above {
y_top
} else {
y_center
};
let end_y = if line.ends_at_commit {
y_center
} else {
y_bottom
};
draw_straight_line(
window, from_x, start_y, from_x, end_y, line_width, color,
);
}
LineType::MergeDown | LineType::BranchOut => {
draw_s_curve(window, from_x, y_center, to_x, y_bottom, line_width, color);
}
}
}
let commit_x =
bounds.origin.x + left_padding + lane_width * lane as f32 + lane_width / 2.0;
let commit_color = accent_colors.color_for_index(commit_color_idx as u32);
let dot_radius = px(4.5);
let stroke_width = px(1.5);
// Draw colored outline only (hollow/transparent circle)
draw_circle_outline(
window,
commit_x,
y_center,
dot_radius,
stroke_width,
commit_color,
);
},
)
.w(graph_width)
.h(row_height)
}
fn draw_circle_outline(
window: &mut Window,
center_x: Pixels,
center_y: Pixels,
radius: Pixels,
stroke_width: Pixels,
color: Hsla,
) {
// Draw a circle outline using path segments
let segments = 32;
let outer_radius = radius;
let inner_radius = radius - stroke_width;
let mut outer_points = Vec::with_capacity(segments);
let mut inner_points = Vec::with_capacity(segments);
for i in 0..segments {
let angle = 2.0 * std::f32::consts::PI * (i as f32) / (segments as f32);
let cos_a = angle.cos();
let sin_a = angle.sin();
outer_points.push(Point::new(
center_x + px(f32::from(outer_radius) * cos_a),
center_y + px(f32::from(outer_radius) * sin_a),
));
inner_points.push(Point::new(
center_x + px(f32::from(inner_radius) * cos_a),
center_y + px(f32::from(inner_radius) * sin_a),
));
}
// Create path: outer circle clockwise, then inner circle counter-clockwise
let mut path = gpui::Path::new(outer_points[0]);
for point in outer_points.iter().skip(1) {
path.line_to(*point);
}
path.line_to(outer_points[0]); // Close outer circle
// Connect to inner circle and trace it in reverse
path.line_to(inner_points[0]);
for point in inner_points.iter().rev() {
path.line_to(*point);
}
window.paint_path(path, color);
}
fn draw_straight_line(
window: &mut Window,
from_x: Pixels,
from_y: Pixels,
to_x: Pixels,
to_y: Pixels,
line_width: Pixels,
color: Hsla,
) {
let half_width = line_width / 2.0;
// Create a path as a thin rectangle for anti-aliased rendering
let mut path = gpui::Path::new(Point::new(from_x - half_width, from_y));
path.line_to(Point::new(from_x + half_width, from_y));
path.line_to(Point::new(to_x + half_width, to_y));
path.line_to(Point::new(to_x - half_width, to_y));
window.paint_path(path, color);
}
fn draw_s_curve(
window: &mut Window,
from_x: Pixels,
from_y: Pixels,
to_x: Pixels,
to_y: Pixels,
line_width: Pixels,
color: Hsla,
) {
if from_x == to_x {
draw_straight_line(window, from_x, from_y, to_x, to_y, line_width, color);
return;
}
let segments = 12;
let half_width = f32::from(line_width / 2.0);
let mid_y = (from_y + to_y) / 2.0;
let mut left_points = Vec::with_capacity(segments + 1);
let mut right_points = Vec::with_capacity(segments + 1);
for i in 0..=segments {
let t = i as f32 / segments as f32;
let (x, y) = cubic_bezier(from_x, from_y, from_x, mid_y, to_x, mid_y, to_x, to_y, t);
let (dx, dy) =
cubic_bezier_derivative(from_x, from_y, from_x, mid_y, to_x, mid_y, to_x, to_y, t);
let dx_f = f32::from(dx);
let dy_f = f32::from(dy);
let len = (dx_f * dx_f + dy_f * dy_f).sqrt();
let (nx, ny) = if len > 0.001 {
(-dy_f / len * half_width, dx_f / len * half_width)
} else {
(half_width, 0.0)
};
left_points.push(Point::new(x - px(nx), y - px(ny)));
right_points.push(Point::new(x + px(nx), y + px(ny)));
}
let mut path = gpui::Path::new(left_points[0]);
for point in left_points.iter().skip(1) {
path.line_to(*point);
}
for point in right_points.iter().rev() {
path.line_to(*point);
}
window.paint_path(path, color);
}
fn cubic_bezier(
p0x: Pixels,
p0y: Pixels,
p1x: Pixels,
p1y: Pixels,
p2x: Pixels,
p2y: Pixels,
p3x: Pixels,
p3y: Pixels,
t: f32,
) -> (Pixels, Pixels) {
let inv_t = 1.0 - t;
let inv_t2 = inv_t * inv_t;
let inv_t3 = inv_t2 * inv_t;
let t2 = t * t;
let t3 = t2 * t;
let x = inv_t3 * p0x + 3.0 * inv_t2 * t * p1x + 3.0 * inv_t * t2 * p2x + t3 * p3x;
let y = inv_t3 * p0y + 3.0 * inv_t2 * t * p1y + 3.0 * inv_t * t2 * p2y + t3 * p3y;
(x, y)
}
fn cubic_bezier_derivative(
p0x: Pixels,
p0y: Pixels,
p1x: Pixels,
p1y: Pixels,
p2x: Pixels,
p2y: Pixels,
p3x: Pixels,
p3y: Pixels,
t: f32,
) -> (Pixels, Pixels) {
let inv_t = 1.0 - t;
let inv_t2 = inv_t * inv_t;
let t2 = t * t;
let dx = 3.0 * inv_t2 * (p1x - p0x) + 6.0 * inv_t * t * (p2x - p1x) + 3.0 * t2 * (p3x - p2x);
let dy = 3.0 * inv_t2 * (p1y - p0y) + 6.0 * inv_t * t * (p2y - p1y) + 3.0 * t2 * (p3y - p2y);
(dx, dy)
}

View File

@@ -18,7 +18,6 @@ futures.workspace = true
git.workspace = true
gpui.workspace = true
http_client.workspace = true
itertools.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -26,7 +26,7 @@ pub fn init(cx: &mut App) {
provider_registry.register_hosting_provider(Arc::new(Gitee));
provider_registry.register_hosting_provider(Arc::new(Github::public_instance()));
provider_registry.register_hosting_provider(Arc::new(Gitlab::public_instance()));
provider_registry.register_hosting_provider(Arc::new(SourceHut::public_instance()));
provider_registry.register_hosting_provider(Arc::new(Sourcehut));
}
/// Registers additional Git hosting providers.
@@ -51,8 +51,6 @@ pub async fn register_additional_providers(
provider_registry.register_hosting_provider(Arc::new(gitea_self_hosted));
} else if let Ok(bitbucket_self_hosted) = Bitbucket::from_remote_url(&origin_url) {
provider_registry.register_hosting_provider(Arc::new(bitbucket_self_hosted));
} else if let Ok(sourcehut_self_hosted) = SourceHut::from_remote_url(&origin_url) {
provider_registry.register_hosting_provider(Arc::new(sourcehut_self_hosted));
}
}

View File

@@ -1,14 +1,8 @@
use std::str::FromStr;
use std::sync::LazyLock;
use std::{str::FromStr, sync::Arc};
use anyhow::{Context as _, Result, bail};
use async_trait::async_trait;
use futures::AsyncReadExt;
use gpui::SharedString;
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
use itertools::Itertools as _;
use anyhow::{Result, bail};
use regex::Regex;
use serde::Deserialize;
use url::Url;
use git::{
@@ -26,42 +20,6 @@ fn pull_request_regex() -> &'static Regex {
&PULL_REQUEST_REGEX
}
#[derive(Debug, Deserialize)]
struct CommitDetails {
author: Author,
}
#[derive(Debug, Deserialize)]
struct Author {
user: Account,
}
#[derive(Debug, Deserialize)]
struct Account {
links: AccountLinks,
}
#[derive(Debug, Deserialize)]
struct AccountLinks {
avatar: Option<Link>,
}
#[derive(Debug, Deserialize)]
struct Link {
href: String,
}
#[derive(Debug, Deserialize)]
struct CommitDetailsSelfHosted {
author: AuthorSelfHosted,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthorSelfHosted {
avatar_url: Option<String>,
}
pub struct Bitbucket {
name: String,
base_url: Url,
@@ -103,60 +61,8 @@ impl Bitbucket {
.host_str()
.is_some_and(|host| host != "bitbucket.org")
}
async fn fetch_bitbucket_commit_author(
&self,
repo_owner: &str,
repo: &str,
commit: &str,
client: &Arc<dyn HttpClient>,
) -> Result<Option<String>> {
let Some(host) = self.base_url.host_str() else {
bail!("failed to get host from bitbucket base url");
};
let is_self_hosted = self.is_self_hosted();
let url = if is_self_hosted {
format!(
"https://{host}/rest/api/latest/projects/{repo_owner}/repos/{repo}/commits/{commit}?avatarSize=128"
)
} else {
format!("https://api.{host}/2.0/repositories/{repo_owner}/{repo}/commit/{commit}")
};
let request = Request::get(&url)
.header("Content-Type", "application/json")
.follow_redirects(http_client::RedirectPolicy::FollowAll);
let mut response = client
.send(request.body(AsyncBody::default())?)
.await
.with_context(|| format!("error fetching BitBucket commit details at {:?}", url))?;
let mut body = Vec::new();
response.body_mut().read_to_end(&mut body).await?;
if response.status().is_client_error() {
let text = String::from_utf8_lossy(body.as_slice());
bail!(
"status error {}, response: {text:?}",
response.status().as_u16()
);
}
let body_str = std::str::from_utf8(&body)?;
if is_self_hosted {
serde_json::from_str::<CommitDetailsSelfHosted>(body_str)
.map(|commit| commit.author.avatar_url)
} else {
serde_json::from_str::<CommitDetails>(body_str)
.map(|commit| commit.author.user.links.avatar.map(|link| link.href))
}
.context("failed to deserialize BitBucket commit details")
}
}
#[async_trait]
impl GitHostingProvider for Bitbucket {
fn name(&self) -> String {
self.name.clone()
@@ -167,7 +73,7 @@ impl GitHostingProvider for Bitbucket {
}
fn supports_avatars(&self) -> bool {
true
false
}
fn format_line_number(&self, line: u32) -> String {
@@ -192,16 +98,9 @@ impl GitHostingProvider for Bitbucket {
return None;
}
let mut path_segments = url.path_segments()?.collect::<Vec<_>>();
let repo = path_segments.pop()?.trim_end_matches(".git");
let owner = if path_segments.get(0).is_some_and(|v| *v == "scm") && path_segments.len() > 1
{
// Skip the "scm" segment if it's not the only segment
// https://github.com/gitkraken/vscode-gitlens/blob/a6e3c6fbb255116507eaabaa9940c192ed7bb0e1/src/git/remotes/bitbucket-server.ts#L72-L74
path_segments.into_iter().skip(1).join("/")
} else {
path_segments.into_iter().join("/")
};
let mut path_segments = url.path_segments()?;
let owner = path_segments.next()?;
let repo = path_segments.next()?.trim_end_matches(".git");
Some(ParsedGitRemote {
owner: owner.into(),
@@ -277,22 +176,6 @@ impl GitHostingProvider for Bitbucket {
Some(PullRequest { number, url })
}
async fn commit_author_avatar_url(
&self,
repo_owner: &str,
repo: &str,
commit: SharedString,
http_client: Arc<dyn HttpClient>,
) -> Result<Option<Url>> {
let commit = commit.to_string();
let avatar_url = self
.fetch_bitbucket_commit_author(repo_owner, repo, &commit, &http_client)
.await?
.map(|avatar_url| Url::parse(&avatar_url))
.transpose()?;
Ok(avatar_url)
}
}
#[cfg(test)]
@@ -381,38 +264,6 @@ mod tests {
repo: "zed".into(),
}
);
// Test with "scm" in the path
let remote_url = "https://bitbucket.company.com/scm/zed-industries/zed.git";
let parsed_remote = Bitbucket::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
// Test with only "scm" as owner
let remote_url = "https://bitbucket.company.com/scm/zed.git";
let parsed_remote = Bitbucket::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "scm".into(),
repo: "zed".into(),
}
);
}
#[test]

View File

@@ -1,6 +1,5 @@
use std::str::FromStr;
use anyhow::{Result, bail};
use url::Url;
use git::{
@@ -8,52 +7,15 @@ use git::{
RemoteUrl,
};
use crate::get_host_from_git_remote_url;
pub struct Sourcehut;
pub struct SourceHut {
name: String,
base_url: Url,
}
impl SourceHut {
pub fn new(name: &str, base_url: Url) -> Self {
Self {
name: name.to_string(),
base_url,
}
}
pub fn public_instance() -> Self {
Self::new("SourceHut", Url::parse("https://git.sr.ht").unwrap())
}
pub fn from_remote_url(remote_url: &str) -> Result<Self> {
let host = get_host_from_git_remote_url(remote_url)?;
if host == "git.sr.ht" {
bail!("the SourceHut instance is not self-hosted");
}
// TODO: detecting self hosted instances by checking whether "sourcehut" is in the url or not
// is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
// information.
if !host.contains("sourcehut") {
bail!("not a SourceHut URL");
}
Ok(Self::new(
"SourceHut Self-Hosted",
Url::parse(&format!("https://{}", host))?,
))
}
}
impl GitHostingProvider for SourceHut {
impl GitHostingProvider for Sourcehut {
fn name(&self) -> String {
self.name.clone()
"SourceHut".to_string()
}
fn base_url(&self) -> Url {
self.base_url.clone()
Url::parse("https://git.sr.ht").unwrap()
}
fn supports_avatars(&self) -> bool {
@@ -72,7 +34,7 @@ impl GitHostingProvider for SourceHut {
let url = RemoteUrl::from_str(url).ok()?;
let host = url.host_str()?;
if host != self.base_url.host_str()? {
if host != "git.sr.ht" {
return None;
}
@@ -134,7 +96,7 @@ mod tests {
#[test]
fn test_parse_remote_url_given_ssh_url() {
let parsed_remote = SourceHut::public_instance()
let parsed_remote = Sourcehut
.parse_remote_url("git@git.sr.ht:~zed-industries/zed")
.unwrap();
@@ -149,7 +111,7 @@ mod tests {
#[test]
fn test_parse_remote_url_given_ssh_url_with_git_suffix() {
let parsed_remote = SourceHut::public_instance()
let parsed_remote = Sourcehut
.parse_remote_url("git@git.sr.ht:~zed-industries/zed.git")
.unwrap();
@@ -164,7 +126,7 @@ mod tests {
#[test]
fn test_parse_remote_url_given_https_url() {
let parsed_remote = SourceHut::public_instance()
let parsed_remote = Sourcehut
.parse_remote_url("https://git.sr.ht/~zed-industries/zed")
.unwrap();
@@ -177,63 +139,9 @@ mod tests {
);
}
#[test]
fn test_parse_remote_url_given_self_hosted_ssh_url() {
let remote_url = "git@sourcehut.org:~zed-industries/zed";
let parsed_remote = SourceHut::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_self_hosted_ssh_url_with_git_suffix() {
let remote_url = "git@sourcehut.org:~zed-industries/zed.git";
let parsed_remote = SourceHut::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed.git".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_self_hosted_https_url() {
let remote_url = "https://sourcehut.org/~zed-industries/zed";
let parsed_remote = SourceHut::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_build_sourcehut_permalink() {
let permalink = SourceHut::public_instance().build_permalink(
let permalink = Sourcehut.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
@@ -251,7 +159,7 @@ mod tests {
#[test]
fn test_build_sourcehut_permalink_with_git_suffix() {
let permalink = SourceHut::public_instance().build_permalink(
let permalink = Sourcehut.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed.git".into(),
@@ -267,49 +175,9 @@ mod tests {
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_self_hosted_permalink() {
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed")
.unwrap()
.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams::new(
"faa6f979be417239b2e070dbbf6392b909224e0b",
&repo_path("crates/editor/src/git/permalink.rs"),
None,
),
);
let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_self_hosted_permalink_with_git_suffix() {
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed.git")
.unwrap()
.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed.git".into(),
},
BuildPermalinkParams::new(
"faa6f979be417239b2e070dbbf6392b909224e0b",
&repo_path("crates/editor/src/git/permalink.rs"),
None,
),
);
let expected_url = "https://sourcehut.org/~zed-industries/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_permalink_with_single_line_selection() {
let permalink = SourceHut::public_instance().build_permalink(
let permalink = Sourcehut.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
@@ -327,7 +195,7 @@ mod tests {
#[test]
fn test_build_sourcehut_permalink_with_multi_line_selection() {
let permalink = SourceHut::public_instance().build_permalink(
let permalink = Sourcehut.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
@@ -342,44 +210,4 @@ mod tests {
let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_self_hosted_permalink_with_single_line_selection() {
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed")
.unwrap()
.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams::new(
"faa6f979be417239b2e070dbbf6392b909224e0b",
&repo_path("crates/editor/src/git/permalink.rs"),
Some(6..6),
),
);
let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_self_hosted_permalink_with_multi_line_selection() {
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed")
.unwrap()
.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams::new(
"faa6f979be417239b2e070dbbf6392b909224e0b",
&repo_path("crates/editor/src/git/permalink.rs"),
Some(23..47),
),
);
let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
}

View File

@@ -8,7 +8,7 @@ use settings::{
use url::Url;
use util::ResultExt as _;
use crate::{Bitbucket, Forgejo, Gitea, Github, Gitlab, SourceHut};
use crate::{Bitbucket, Github, Gitlab};
pub(crate) fn init(cx: &mut App) {
init_git_hosting_provider_settings(cx);
@@ -46,11 +46,6 @@ fn update_git_hosting_providers_from_settings(cx: &mut App) {
}
GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _,
GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _,
GitHostingProviderKind::Gitea => Arc::new(Gitea::new(&provider.name, url)) as _,
GitHostingProviderKind::Forgejo => Arc::new(Forgejo::new(&provider.name, url)) as _,
GitHostingProviderKind::SourceHut => {
Arc::new(SourceHut::new(&provider.name, url)) as _
}
})
});

View File

@@ -375,7 +375,6 @@ pub struct JobsUpdated;
#[derive(Debug)]
pub enum GitStoreEvent {
ActiveRepositoryChanged(Option<RepositoryId>),
/// Bool is true when the repository that's updated is the active repository
RepositoryUpdated(RepositoryId, RepositoryEvent, bool),
RepositoryAdded,
RepositoryRemoved(RepositoryId),

View File

@@ -543,7 +543,7 @@ pub enum DiagnosticSeverityContent {
pub struct GitHostingProviderConfig {
/// The type of the provider.
///
/// Must be one of `github`, `gitlab`, `bitbucket`, `gitea`, `forgejo`, or `source_hut`.
/// Must be one of `github`, `gitlab`, or `bitbucket`.
pub provider: GitHostingProviderKind,
/// The base URL for the provider (e.g., "https://code.corp.big.com").
@@ -559,7 +559,4 @@ pub enum GitHostingProviderKind {
Github,
Gitlab,
Bitbucket,
Gitea,
Forgejo,
SourceHut,
}

View File

@@ -63,7 +63,6 @@ file_finder.workspace = true
fs.workspace = true
futures.workspace = true
git.workspace = true
git_graph.workspace = true
git_hosting_providers.workspace = true
git_ui.workspace = true
go_to_line.workspace = true

View File

@@ -634,7 +634,6 @@ pub fn main() {
notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx);
collab_ui::init(&app_state, cx);
git_ui::init(cx);
git_graph::init(cx);
feedback::init(cx);
markdown_preview::init(cx);
svg_preview::init(cx);

View File

@@ -4930,7 +4930,6 @@ mod tests {
language_model::init(app_state.client.clone(), cx);
language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
web_search::init(cx);
git_graph::init(cx);
web_search_providers::init(app_state.client.clone(), cx);
let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx);
agent_ui::init(