Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1143de1d06 | ||
|
|
99e7aef145 | ||
|
|
80956e2037 | ||
|
|
391f6f1b04 | ||
|
|
38bb2ba7da | ||
|
|
4303e8e781 | ||
|
|
ed1dd89c44 | ||
|
|
94526ad28c | ||
|
|
ee4cd4d27a | ||
|
|
74df6d7db3 | ||
|
|
5080697b9b | ||
|
|
92affa2bf2 | ||
|
|
7479942fd2 | ||
|
|
8e6f2f5d97 | ||
|
|
d74612ce24 | ||
|
|
ce0f5259bc | ||
|
|
8e78337ec9 | ||
|
|
62bcaf41ee | ||
|
|
3bb908ce5d | ||
|
|
f86476a480 | ||
|
|
a686fc106a | ||
|
|
700d3cabac | ||
|
|
e8807aaa58 | ||
|
|
84b787ff32 | ||
|
|
ed6165f450 | ||
|
|
efc5c93d9c |
23
Cargo.lock
generated
23
Cargo.lock
generated
@@ -6997,6 +6997,28 @@ 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"
|
||||
@@ -20515,6 +20537,7 @@ dependencies = [
|
||||
"fs",
|
||||
"futures 0.3.31",
|
||||
"git",
|
||||
"git_graph",
|
||||
"git_hosting_providers",
|
||||
"git_ui",
|
||||
"go_to_line",
|
||||
|
||||
@@ -75,6 +75,7 @@ members = [
|
||||
"crates/fsevent",
|
||||
"crates/fuzzy",
|
||||
"crates/git",
|
||||
"crates/git_graph",
|
||||
"crates/git_hosting_providers",
|
||||
"crates/git_ui",
|
||||
"crates/go_to_line",
|
||||
@@ -299,6 +300,7 @@ 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
33
crates/git_graph/Cargo.toml
Normal file
33
crates/git_graph/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[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
|
||||
1
crates/git_graph/LICENSE-GPL
Symbolic link
1
crates/git_graph/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
578
crates/git_graph/src/git_graph.rs
Normal file
578
crates/git_graph/src/git_graph.rs
Normal file
@@ -0,0 +1,578 @@
|
||||
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 = ?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
363
crates/git_graph/src/graph.rs
Normal file
363
crates/git_graph/src/graph.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
343
crates/git_graph/src/graph_rendering.rs
Normal file
343
crates/git_graph/src/graph_rendering.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
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)
|
||||
}
|
||||
@@ -375,6 +375,7 @@ 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),
|
||||
|
||||
@@ -63,6 +63,7 @@ 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
|
||||
|
||||
@@ -634,6 +634,7 @@ 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);
|
||||
|
||||
@@ -4930,6 +4930,7 @@ 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(
|
||||
|
||||
Reference in New Issue
Block a user