Compare commits

..

29 Commits

Author SHA1 Message Date
Anthony Eid
1143de1d06 More shredding 2025-12-12 16:49:17 -05:00
Anthony Eid
99e7aef145 Fix bug where git graph would load from wrong starting point 2025-12-12 16:45:32 -05:00
Anthony Eid
80956e2037 Fix loading delay 2025-12-12 05:02:10 -05:00
Anthony Eid
391f6f1b04 Start work on fetching commit chunks instead of all at once
Co-authored-by: Cole Miller <cole@zed.dev>
2025-12-11 12:28:16 -05:00
Anthony Eid
38bb2ba7da Limit max count of commits 2025-12-11 06:02:07 -05:00
Anthony Eid
4303e8e781 WIP 2025-12-11 05:56:06 -05:00
Anthony Eid
ed1dd89c44 WIP get git graph to be drawn with one canvas draw 2025-12-11 05:39:01 -05:00
Anthony Eid
94526ad28c Use accent colors for git graph 2025-12-11 04:06:40 -05:00
Anthony Eid
ee4cd4d27a More shredding 2025-12-11 03:46:17 -05:00
Anthony Eid
74df6d7db3 More shreding 2025-12-11 03:41:25 -05:00
Anthony Eid
5080697b9b Make git graph a serializable item 2025-12-11 03:39:18 -05:00
Anthony Eid
92affa2bf2 More shreding 2025-12-11 03:15:13 -05:00
Anthony Eid
7479942fd2 More shreddddd 2025-12-11 03:09:26 -05:00
Anthony Eid
8e6f2f5d97 Start the mega shred mhahahahah 2025-12-11 02:41:51 -05:00
Anthony Eid
d74612ce24 UI code clean up 2025-12-11 02:15:49 -05:00
Anthony Eid
ce0f5259bc Remove some tests 2025-12-11 01:58:40 -05:00
Anthony Eid
8e78337ec9 Remove warnings 2025-12-11 01:52:51 -05:00
Anthony Eid
62bcaf41ee Fix bug where initial commit wouldn't be drawned correctly 2025-12-11 01:50:29 -05:00
Anthony Eid
3bb908ce5d Reset files to match original PR
Co-authored-by: pyundev <pyundev@users.noreply.github.com>
2025-12-11 01:44:18 -05:00
Anthony Eid
f86476a480 Remove more unused code 2025-12-11 01:24:57 -05:00
Anthony Eid
a686fc106a Fix wrong commits being filtered out 2025-12-11 01:23:07 -05:00
Anthony Eid
700d3cabac Hook up different backend to git graph ui 2025-12-11 01:20:17 -05:00
Anthony Eid
e8807aaa58 WIP 2025-12-09 14:41:39 -05:00
Anthony Eid
84b787ff32 Take base git graph UI from PR: 44405
https://github.com/zed-industries/zed/pull/44405

Co-authored-by: pyundev <pyundev@users.noreply.github.com>
2025-12-08 17:30:14 -05:00
Anthony Eid
ed6165f450 Have opus generate the init graph building code based off of gitamine 2025-12-08 16:59:46 -05:00
Anthony Eid
efc5c93d9c Initial git graph crate setup 2025-12-08 15:58:46 -05:00
ᴀᴍᴛᴏᴀᴇʀ
b948d8b9e7 git: Improve self-hosted provider support and Bitbucket integration (#42343)
This PR includes several minor modifications and improvements related to
Git hosting providers, covering the following areas:

1. Bitbucket Owner Parsing Fix: Remove the common `scm` prefix from the
remote URL of self-hosted Bitbucket instances to prevent incorrect owner
parsing.
[Reference](a6e3c6fbb2/src/git/remotes/bitbucket-server.ts (L72-L74))
2. Bitbucket Avatars in Blame: Add support for displaying Bitbucket
avatars in the Git blame view.
<img width="2750" height="1994" alt="CleanShot 2025-11-10 at 20 34
40@2x"
src="https://github.com/user-attachments/assets/9e26abdf-7880-4085-b636-a1f99ebeeb97"
/>
3. Self-hosted SourceHut Support: Add support for self-hosted SourceHut
instances.
4. Configuration: Add recently introduced self-hosted Git providers
(Gitea, Forgejo, and SourceHut) to the `git_hosting_providers` setting
option.
<img width="2750" height="1994" alt="CleanShot 2025-11-10 at 20 33
48@2x"
src="https://github.com/user-attachments/assets/44ffc799-182d-4145-9b89-e509bbc08843"
/>


Closes #11043

Release Notes:

- Improved self-hosted git provider support and Bitbucket integration
2025-12-08 13:32:14 -05:00
Floyd Wang
bc17491527 gpui: Revert grid template columns default behavior to align with Tailwind (#44368)
When using the latest version of `GPUI`, I found some grid layout
issues. I discovered #43555 modified the default behavior of grid
template columns. I checked the implementation at
https://tailwindcss.com/docs/grid-template-columns, and it seems our
previous implementation was correct.

If a grid layout is placed inside a flexbox, the layout becomes
unpredictable.

```rust
impl Render for HelloWorld {
    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .flex()
            .size(px(500.0))
            .bg(rgb(0x505050))
            .text_xl()
            .text_color(rgb(0xffffff))
            .child(
                div()
                    .size_full()
                    .gap_1()
                    .grid()
                    .grid_cols(2)
                    .border_1()
                    .border_color(gpui::red())
                    .children((0..10).map(|ix| {
                        div()
                            .w_full()
                            .border_1()
                            .border_color(gpui::green())
                            .child(ix.to_string())
                    })),
            )
    }
}
```

| Before | After |
| - | - |
| <img width="612" height="644" alt="After1"
src="https://github.com/user-attachments/assets/64eaf949-0f38-4f0b-aae7-6637f8f40038"
/> | <img width="612" height="644" alt="Before1"
src="https://github.com/user-attachments/assets/561a508d-29ea-4fd2-bd1e-909ad14b9ee3"
/> |

I also placed the grid layout example inside a flexbox too.

| Before | After |
| - | - |
| <img width="612" height="644" alt="After"
src="https://github.com/user-attachments/assets/fa6f4a2d-21d8-413e-8b66-7bd073e05f87"
/> | <img width="612" height="644" alt="Before"
src="https://github.com/user-attachments/assets/9e0783d1-18e9-470d-b913-0dbe4ba88835"
/> |

I tested the changes from the previous PR, and it seems that setting the
table's parent to `v_flex` is sufficient to achieve a non-full table
width without modifying the grid layout. This was already done in the
previous PR.

I reverted the grid changes, the blue border represents the table width.
cc @RemcoSmitsDev

<img width="1107" height="1000" alt="table"
src="https://github.com/user-attachments/assets/4b7ba2a2-a66a-444d-ad42-d80bc9057cce"
/>

So, I believe we should revert to this implementation to align with
tailwindcss behavior and avoid potential future problems, especially
since the cause of this issue is difficult to pinpoint.

Release Notes:

- N/A
2025-12-08 17:38:10 +01:00
ozzy
f6a6630171 agent_ui: Auto-capture file context on paste (#42982)
Closes #42972


https://github.com/user-attachments/assets/98f2d3dc-5682-4670-b636-fa8ea2495c69

Release Notes:

- Added automatic file context detection when pasting code into the AI
agent panel. Pasted code now displays as collapsible badges showing the
file path and line numbers (e.g., "app/layout.tsx (18-25)").

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-12-08 17:32:04 +01:00
19 changed files with 1923 additions and 244 deletions

24
Cargo.lock generated
View File

@@ -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"
@@ -7008,6 +7030,7 @@ dependencies = [
"gpui",
"http_client",
"indoc",
"itertools 0.14.0",
"pretty_assertions",
"regex",
"serde",
@@ -20514,6 +20537,7 @@ dependencies = [
"fs",
"futures 0.3.31",
"git",
"git_graph",
"git_hosting_providers",
"git_ui",
"go_to_line",

View File

@@ -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

View 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

View File

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

View 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 = ?
}
}
}
}

View 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,
}));
}
}
}

View 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)
}

View File

@@ -18,6 +18,7 @@ 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));
provider_registry.register_hosting_provider(Arc::new(SourceHut::public_instance()));
}
/// Registers additional Git hosting providers.
@@ -51,6 +51,8 @@ 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,8 +1,14 @@
use std::str::FromStr;
use std::sync::LazyLock;
use std::{str::FromStr, sync::Arc};
use anyhow::{Result, bail};
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 regex::Regex;
use serde::Deserialize;
use url::Url;
use git::{
@@ -20,6 +26,42 @@ 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,
@@ -61,8 +103,60 @@ 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()
@@ -73,7 +167,7 @@ impl GitHostingProvider for Bitbucket {
}
fn supports_avatars(&self) -> bool {
false
true
}
fn format_line_number(&self, line: u32) -> String {
@@ -98,9 +192,16 @@ impl GitHostingProvider for Bitbucket {
return None;
}
let mut path_segments = url.path_segments()?;
let owner = path_segments.next()?;
let repo = path_segments.next()?.trim_end_matches(".git");
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("/")
};
Some(ParsedGitRemote {
owner: owner.into(),
@@ -176,6 +277,22 @@ 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)]
@@ -264,6 +381,38 @@ 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,5 +1,6 @@
use std::str::FromStr;
use anyhow::{Result, bail};
use url::Url;
use git::{
@@ -7,15 +8,52 @@ use git::{
RemoteUrl,
};
pub struct Sourcehut;
use crate::get_host_from_git_remote_url;
impl GitHostingProvider for 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 {
fn name(&self) -> String {
"SourceHut".to_string()
self.name.clone()
}
fn base_url(&self) -> Url {
Url::parse("https://git.sr.ht").unwrap()
self.base_url.clone()
}
fn supports_avatars(&self) -> bool {
@@ -34,7 +72,7 @@ impl GitHostingProvider for Sourcehut {
let url = RemoteUrl::from_str(url).ok()?;
let host = url.host_str()?;
if host != "git.sr.ht" {
if host != self.base_url.host_str()? {
return None;
}
@@ -96,7 +134,7 @@ mod tests {
#[test]
fn test_parse_remote_url_given_ssh_url() {
let parsed_remote = Sourcehut
let parsed_remote = SourceHut::public_instance()
.parse_remote_url("git@git.sr.ht:~zed-industries/zed")
.unwrap();
@@ -111,7 +149,7 @@ mod tests {
#[test]
fn test_parse_remote_url_given_ssh_url_with_git_suffix() {
let parsed_remote = Sourcehut
let parsed_remote = SourceHut::public_instance()
.parse_remote_url("git@git.sr.ht:~zed-industries/zed.git")
.unwrap();
@@ -126,7 +164,7 @@ mod tests {
#[test]
fn test_parse_remote_url_given_https_url() {
let parsed_remote = Sourcehut
let parsed_remote = SourceHut::public_instance()
.parse_remote_url("https://git.sr.ht/~zed-industries/zed")
.unwrap();
@@ -139,9 +177,63 @@ 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.build_permalink(
let permalink = SourceHut::public_instance().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
@@ -159,7 +251,7 @@ mod tests {
#[test]
fn test_build_sourcehut_permalink_with_git_suffix() {
let permalink = Sourcehut.build_permalink(
let permalink = SourceHut::public_instance().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed.git".into(),
@@ -175,9 +267,49 @@ 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.build_permalink(
let permalink = SourceHut::public_instance().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
@@ -195,7 +327,7 @@ mod tests {
#[test]
fn test_build_sourcehut_permalink_with_multi_line_selection() {
let permalink = Sourcehut.build_permalink(
let permalink = SourceHut::public_instance().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
@@ -210,4 +342,44 @@ 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, Github, Gitlab};
use crate::{Bitbucket, Forgejo, Gitea, Github, Gitlab, SourceHut};
pub(crate) fn init(cx: &mut App) {
init_git_hosting_provider_settings(cx);
@@ -46,6 +46,11 @@ 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

@@ -8,7 +8,6 @@ use std::{fmt::Debug, ops::Range};
use taffy::{
TaffyTree, TraversePartialTree as _,
geometry::{Point as TaffyPoint, Rect as TaffyRect, Size as TaffySize},
prelude::min_content,
style::AvailableSpace as TaffyAvailableSpace,
tree::NodeId,
};
@@ -296,7 +295,7 @@ trait ToTaffy<Output> {
impl ToTaffy<taffy::style::Style> for Style {
fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::Style {
use taffy::style_helpers::{length, minmax, repeat};
use taffy::style_helpers::{fr, length, minmax, repeat};
fn to_grid_line(
placement: &Range<crate::GridPlacement>,
@@ -310,8 +309,8 @@ impl ToTaffy<taffy::style::Style> for Style {
fn to_grid_repeat<T: taffy::style::CheapCloneStr>(
unit: &Option<u16>,
) -> Vec<taffy::GridTemplateComponent<T>> {
// grid-template-columns: repeat(<number>, minmax(0, min-content));
unit.map(|count| vec![repeat(count, vec![minmax(length(0.0), min_content())])])
// grid-template-columns: repeat(<number>, minmax(0, 1fr));
unit.map(|count| vec![repeat(count, vec![minmax(length(0.0), fr(1.0))])])
.unwrap_or_default()
}

View File

@@ -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),

View File

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

View File

@@ -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

View File

@@ -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);

View File

@@ -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(