feat: improve repository history navigation
This commit is contained in:
@@ -2,6 +2,10 @@ use gotcha_gitea::models;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum Target {
|
||||
Repository {
|
||||
owner: String,
|
||||
repository: String,
|
||||
},
|
||||
Commit {
|
||||
owner: String,
|
||||
repository: String,
|
||||
@@ -30,6 +34,10 @@ pub fn target(activity: &models::Activity) -> Option<Target> {
|
||||
.split_once('/')?;
|
||||
let pair = || (owner.to_string(), repository.to_string());
|
||||
match activity.op_type? {
|
||||
OpType::CreateRepo => {
|
||||
let (owner, repository) = pair();
|
||||
Some(Target::Repository { owner, repository })
|
||||
}
|
||||
OpType::CommitRepo | OpType::MirrorSyncPush => {
|
||||
let (owner, repository) = pair();
|
||||
Some(Target::Commit {
|
||||
@@ -133,6 +141,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn decodes_linkable_activity_targets() {
|
||||
assert_eq!(
|
||||
target(&activity(OpType::CreateRepo, "")),
|
||||
Some(Target::Repository {
|
||||
owner: "octo".into(),
|
||||
repository: "demo".into(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
target(&activity(OpType::CreatePullRequest, "42|feature")),
|
||||
Some(Target::Pull {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::{BTreeSet, BinaryHeap, HashMap},
|
||||
};
|
||||
|
||||
use gotcha_gitea::{Client, apis, models};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use crate::{
|
||||
domain::{HomeData, RepositoryData, Server},
|
||||
domain::{HistoryCommit, HomeData, RepositoryData, Server},
|
||||
view::compact_date,
|
||||
};
|
||||
|
||||
@@ -40,6 +46,7 @@ pub async fn load_repositories(server: &Server) -> Result<Vec<RepositoryData>, S
|
||||
.unwrap_or_else(|| "Unknown language".into()),
|
||||
open_issues: repository.open_issues_count.unwrap_or_default(),
|
||||
updated: compact_date(repository.updated_at.as_deref()),
|
||||
default_branch: repository.default_branch.unwrap_or_else(|| "main".into()),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
@@ -116,30 +123,342 @@ pub async fn load_pulls(server: &Server, status: &str) -> Result<Vec<models::Iss
|
||||
Ok(pulls)
|
||||
}
|
||||
|
||||
pub async fn load_commits(
|
||||
pub async fn load_branches(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
default_branch: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
let configuration = client.configuration();
|
||||
let mut branches = Vec::new();
|
||||
for page in 1.. {
|
||||
let batch = apis::repository_api::repo_list_branches(
|
||||
&configuration,
|
||||
owner,
|
||||
repository,
|
||||
Some(page),
|
||||
Some(100),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let done = batch.len() < 100;
|
||||
branches.extend(batch.into_iter().filter_map(|branch| branch.name));
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
branches.sort_by_key(|branch| (branch != default_branch, branch.to_lowercase()));
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
pub async fn load_branch_commits(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
branch: &str,
|
||||
) -> Result<Vec<HistoryCommit>, String> {
|
||||
load_commits_for_ref(server, owner, repository, Some(branch))
|
||||
.await
|
||||
.map(|commits| {
|
||||
commits
|
||||
.into_iter()
|
||||
.map(|commit| HistoryCommit {
|
||||
commit,
|
||||
top_lanes: Vec::new(),
|
||||
bottom_lanes: Vec::new(),
|
||||
node_lane: None,
|
||||
connections: Vec::new(),
|
||||
refs: Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn load_all_commits(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
branches: &[String],
|
||||
) -> Result<Vec<HistoryCommit>, String> {
|
||||
let mut tasks = JoinSet::new();
|
||||
for (lane, branch) in branches.iter().cloned().enumerate() {
|
||||
let (server, owner, repository) =
|
||||
(server.clone(), owner.to_string(), repository.to_string());
|
||||
tasks.spawn(async move {
|
||||
let commits = load_commits_for_ref(&server, &owner, &repository, Some(&branch)).await?;
|
||||
Ok::<_, String>((lane, branch, commits))
|
||||
});
|
||||
}
|
||||
|
||||
let mut histories = Vec::new();
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
histories.push(result.map_err(|error| error.to_string())??);
|
||||
}
|
||||
histories.sort_by_key(|(lane, _, _)| *lane);
|
||||
|
||||
let pull_refs = load_pull_refs(server, owner, repository)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
Ok(build_graph(histories, pull_refs))
|
||||
}
|
||||
|
||||
pub async fn load_pull_files(
|
||||
configuration: &apis::configuration::Configuration,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
number: i64,
|
||||
) -> Result<Vec<models::ChangedFile>, String> {
|
||||
let mut files = Vec::new();
|
||||
for page in 1.. {
|
||||
let batch = apis::repository_api::repo_get_pull_request_files(
|
||||
configuration,
|
||||
owner,
|
||||
repository,
|
||||
number,
|
||||
None,
|
||||
None,
|
||||
Some(page),
|
||||
Some(100),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let done = batch.len() < 100;
|
||||
files.extend(batch);
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
async fn load_pull_refs(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
) -> Result<HashMap<String, Vec<String>>, String> {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
let configuration = client.configuration();
|
||||
let mut refs: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for page in 1.. {
|
||||
let batch = apis::repository_api::repo_list_pull_requests(
|
||||
&configuration,
|
||||
owner,
|
||||
repository,
|
||||
None,
|
||||
Some("all"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(page),
|
||||
Some(100),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let done = batch.len() < 100;
|
||||
for pull in batch {
|
||||
let Some(head) = pull.head else { continue };
|
||||
let Some(sha) = head.sha else { continue };
|
||||
let Some(label) = head.label.or(head.r#ref) else {
|
||||
continue;
|
||||
};
|
||||
if !label.is_empty() {
|
||||
refs.entry(sha).or_default().push(label);
|
||||
}
|
||||
}
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(refs)
|
||||
}
|
||||
|
||||
fn build_graph(
|
||||
histories: Vec<(usize, String, Vec<models::Commit>)>,
|
||||
mut extra_refs: HashMap<String, Vec<String>>,
|
||||
) -> Vec<HistoryCommit> {
|
||||
let mut commits = HashMap::new();
|
||||
let mut ranks: HashMap<String, (usize, usize)> = HashMap::new();
|
||||
let mut refs: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for (branch_index, branch, history) in histories {
|
||||
if let Some(sha) = history.iter().find_map(|commit| commit.sha.clone()) {
|
||||
refs.entry(sha).or_default().push(branch);
|
||||
}
|
||||
for (index, commit) in history.into_iter().enumerate() {
|
||||
let Some(sha) = commit.sha.clone() else {
|
||||
continue;
|
||||
};
|
||||
ranks
|
||||
.entry(sha.clone())
|
||||
.and_modify(|rank| *rank = (*rank).min((branch_index, index)))
|
||||
.or_insert((branch_index, index));
|
||||
commits.entry(sha).or_insert(commit);
|
||||
}
|
||||
}
|
||||
for (sha, labels) in extra_refs.drain() {
|
||||
let row = refs.entry(sha).or_default();
|
||||
for label in labels {
|
||||
if !row.contains(&label) {
|
||||
row.push(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut children = HashMap::new();
|
||||
for sha in commits.keys() {
|
||||
children.insert(sha.clone(), 0_usize);
|
||||
}
|
||||
for commit in commits.values() {
|
||||
for parent in commit_parents(commit) {
|
||||
if let Some(count) = children.get_mut(parent) {
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ready = BinaryHeap::new();
|
||||
for (sha, count) in &children {
|
||||
if *count == 0 {
|
||||
ready.push(Reverse((ranks[sha], sha.clone())));
|
||||
}
|
||||
}
|
||||
let mut ordered = Vec::with_capacity(commits.len());
|
||||
while let Some(Reverse((_, sha))) = ready.pop() {
|
||||
let Some(commit) = commits.remove(&sha) else {
|
||||
continue;
|
||||
};
|
||||
for parent in commit_parents(&commit) {
|
||||
if let Some(count) = children.get_mut(parent) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
ready.push(Reverse((ranks[parent], parent.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
ordered.push(HistoryCommit {
|
||||
commit,
|
||||
top_lanes: Vec::new(),
|
||||
bottom_lanes: Vec::new(),
|
||||
node_lane: None,
|
||||
connections: Vec::new(),
|
||||
refs: refs.remove(&sha).unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
layout_graph(&mut ordered);
|
||||
ordered
|
||||
}
|
||||
|
||||
fn layout_graph(commits: &mut [HistoryCommit]) {
|
||||
let mut lanes: Vec<Option<String>> = Vec::new();
|
||||
for row in commits {
|
||||
let Some(sha) = row.commit.sha.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let matching_lanes: Vec<_> = lanes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, next)| (next.as_deref() == Some(sha)).then_some(index))
|
||||
.collect();
|
||||
let existing_node = matching_lanes.first().copied();
|
||||
let node = existing_node.unwrap_or_else(|| allocate_lane(&mut lanes, sha));
|
||||
row.top_lanes = lanes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, lane)| lane.as_ref().map(|_| index))
|
||||
.filter(|index| existing_node.is_some() || *index != node)
|
||||
.collect();
|
||||
let mut connections = BTreeSet::new();
|
||||
for duplicate in matching_lanes.into_iter().skip(1) {
|
||||
lanes[duplicate] = None;
|
||||
connect(node, duplicate, &mut connections);
|
||||
}
|
||||
let parents: Vec<_> = commit_parents(&row.commit).map(str::to_string).collect();
|
||||
lanes[node] = None;
|
||||
for (index, parent) in parents.into_iter().enumerate() {
|
||||
if index == 0 {
|
||||
lanes[node] = Some(parent);
|
||||
} else if let Some(existing) = lanes
|
||||
.iter()
|
||||
.position(|next| next.as_deref() == Some(&parent))
|
||||
{
|
||||
connect(node, existing, &mut connections);
|
||||
} else {
|
||||
let branch = allocate_lane(&mut lanes, &parent);
|
||||
connect(node, branch, &mut connections);
|
||||
}
|
||||
}
|
||||
row.node_lane = Some(node);
|
||||
row.connections = connections.into_iter().collect();
|
||||
row.bottom_lanes = lanes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, lane)| lane.as_ref().map(|_| index))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
fn allocate_lane(lanes: &mut Vec<Option<String>>, sha: &str) -> usize {
|
||||
if let Some(index) = lanes.iter().position(Option::is_none) {
|
||||
lanes[index] = Some(sha.into());
|
||||
index
|
||||
} else {
|
||||
lanes.push(Some(sha.into()));
|
||||
lanes.len() - 1
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(left: usize, right: usize, connections: &mut BTreeSet<usize>) {
|
||||
for lane in left.min(right) + 1..=left.max(right) {
|
||||
connections.insert(lane);
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_parents(commit: &models::Commit) -> impl Iterator<Item = &str> {
|
||||
commit
|
||||
.parents
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|parent| parent.sha.as_deref())
|
||||
}
|
||||
|
||||
async fn load_commits_for_ref(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
branch: Option<&str>,
|
||||
) -> Result<Vec<models::Commit>, String> {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
apis::repository_api::repo_get_all_commits(
|
||||
&client.configuration(),
|
||||
owner,
|
||||
repository,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(false),
|
||||
Some(1),
|
||||
Some(100),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
let configuration = client.configuration();
|
||||
let mut commits = Vec::new();
|
||||
for page in 1.. {
|
||||
let batch = apis::repository_api::repo_get_all_commits(
|
||||
&configuration,
|
||||
owner,
|
||||
repository,
|
||||
branch,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(false),
|
||||
Some(page),
|
||||
Some(100),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let done = batch.len() < 100;
|
||||
commits.extend(batch);
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
pub async fn load_home(server: &Server) -> Result<HomeData, String> {
|
||||
@@ -168,3 +487,64 @@ pub async fn load_home(server: &Server) -> Result<HomeData, String> {
|
||||
heatmap: heatmap.map_err(|error| error.to_string())?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn commit(sha: &str, parents: &[&str]) -> models::Commit {
|
||||
models::Commit {
|
||||
sha: Some(sha.into()),
|
||||
parents: Some(
|
||||
parents
|
||||
.iter()
|
||||
.map(|sha| models::CommitMeta {
|
||||
sha: Some((*sha).into()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lays_out_merged_pull_request_on_its_parent_lane() {
|
||||
let commits = build_graph(
|
||||
vec![(
|
||||
0,
|
||||
"main".into(),
|
||||
vec![
|
||||
commit("tip", &["merge"]),
|
||||
commit("merge", &["main-before", "feature"]),
|
||||
commit("feature", &["base"]),
|
||||
commit("main-before", &["base"]),
|
||||
commit("base", &[]),
|
||||
],
|
||||
)],
|
||||
HashMap::from([("feature".into(), vec!["linux-refactoring-base".into()])]),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
commits
|
||||
.iter()
|
||||
.map(|commit| commit.commit.sha.as_deref().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["tip", "merge", "feature", "main-before", "base"]
|
||||
);
|
||||
assert_eq!(commits[0].refs, ["main"]);
|
||||
assert_eq!(commits[1].connections, [1]);
|
||||
assert_eq!(commits[2].refs, ["linux-refactoring-base"]);
|
||||
assert_eq!(commits[2].node_lane, Some(1));
|
||||
assert_eq!(commits[1].top_lanes, [0]);
|
||||
assert_eq!(commits[1].bottom_lanes, [0, 1]);
|
||||
assert_eq!(commits[2].top_lanes, [0, 1]);
|
||||
assert_eq!(commits[2].bottom_lanes, [0, 1]);
|
||||
assert!(commits[3].connections.is_empty());
|
||||
assert_eq!(commits[3].bottom_lanes, [0, 1]);
|
||||
assert_eq!(commits[4].top_lanes, [0, 1]);
|
||||
assert_eq!(commits[4].connections, [1]);
|
||||
assert!(commits[4].bottom_lanes.is_empty());
|
||||
assert_eq!(commits[4].node_lane, Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,17 @@ pub struct RepositoryData {
|
||||
pub language: String,
|
||||
pub open_issues: i64,
|
||||
pub updated: String,
|
||||
pub default_branch: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HistoryCommit {
|
||||
pub commit: models::Commit,
|
||||
pub top_lanes: Vec<usize>,
|
||||
pub bottom_lanes: Vec<usize>,
|
||||
pub node_lane: Option<usize>,
|
||||
pub connections: Vec<usize>,
|
||||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -59,7 +70,9 @@ pub struct State {
|
||||
pub repositories: Vec<RepositoryData>,
|
||||
pub issues: Vec<models::Issue>,
|
||||
pub pulls: Vec<models::Issue>,
|
||||
pub commits: Vec<models::Commit>,
|
||||
pub branches: Vec<String>,
|
||||
pub active_branch: Option<String>,
|
||||
pub commits: Vec<HistoryCommit>,
|
||||
}
|
||||
|
||||
pub struct HomeData {
|
||||
@@ -75,6 +88,7 @@ pub struct IssueDetails {
|
||||
pub struct PullDetails {
|
||||
pub pull: models::PullRequest,
|
||||
pub comments: Vec<models::Comment>,
|
||||
pub files: Vec<models::ChangedFile>,
|
||||
}
|
||||
|
||||
pub fn open_status() -> String {
|
||||
|
||||
@@ -390,6 +390,34 @@ fn wire_callbacks(ui: &AppWindow, state: Arc<Mutex<State>>) {
|
||||
}
|
||||
});
|
||||
|
||||
ui.on_select_branch({
|
||||
let weak = ui.as_weak();
|
||||
let state = state.clone();
|
||||
move |selection| {
|
||||
let target = {
|
||||
let mut state = state.lock().unwrap();
|
||||
let branch = (selection.as_str() != "All").then(|| selection.to_string());
|
||||
if branch
|
||||
.as_ref()
|
||||
.is_some_and(|branch| !state.branches.contains(branch))
|
||||
{
|
||||
return;
|
||||
}
|
||||
state.active_branch = branch;
|
||||
state.commits.clear();
|
||||
state
|
||||
.active_server
|
||||
.and_then(|index| state.preferences.servers.get(index))
|
||||
.cloned()
|
||||
.zip(state.active_repository.clone())
|
||||
};
|
||||
if let (Some(ui), Some((server, (owner, repository)))) = (weak.upgrade(), target) {
|
||||
ui.set_commits(empty_model());
|
||||
fetch_commits(&ui, state.clone(), server, owner, repository, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.on_select_file({
|
||||
let weak = ui.as_weak();
|
||||
let state = state.clone();
|
||||
@@ -410,6 +438,26 @@ fn wire_callbacks(ui: &AppWindow, state: Arc<Mutex<State>>) {
|
||||
}
|
||||
});
|
||||
|
||||
ui.on_select_pull_file({
|
||||
let weak = ui.as_weak();
|
||||
let state = state.clone();
|
||||
move |path| {
|
||||
let target = {
|
||||
let state = state.lock().unwrap();
|
||||
state
|
||||
.active_server
|
||||
.and_then(|index| state.preferences.servers.get(index))
|
||||
.cloned()
|
||||
.zip(state.active_pull.clone())
|
||||
};
|
||||
if let (Some(ui), Some((server, (owner, repository, number)))) =
|
||||
(weak.upgrade(), target)
|
||||
{
|
||||
fetch_pull_file_diff(&ui, server, owner, repository, number, path.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.on_select_activity({
|
||||
let weak = ui.as_weak();
|
||||
let state = state.clone();
|
||||
@@ -427,6 +475,15 @@ fn wire_callbacks(ui: &AppWindow, state: Arc<Mutex<State>>) {
|
||||
return;
|
||||
};
|
||||
match target.as_str() {
|
||||
"repository" => {
|
||||
ui.set_tab("repositories".into());
|
||||
open_commit_history(
|
||||
&ui,
|
||||
state.clone(),
|
||||
owner.to_string(),
|
||||
repository.to_string(),
|
||||
);
|
||||
}
|
||||
"issue" => {
|
||||
state.lock().unwrap().active_issue = Some(number as i64);
|
||||
ui.set_tab("issues".into());
|
||||
@@ -525,6 +582,7 @@ fn wire_callbacks(ui: &AppWindow, state: Arc<Mutex<State>>) {
|
||||
}
|
||||
ui.set_page(match (ui.get_tab().as_str(), ui.get_page().as_str()) {
|
||||
("pulls", "pull") => "pulls".into(),
|
||||
("pulls", "diff") => "pull".into(),
|
||||
("repositories", "diff") => "files".into(),
|
||||
("repositories", "files") => "commits".into(),
|
||||
("repositories", "commits") => "repositories".into(),
|
||||
@@ -802,7 +860,7 @@ fn fetch_pull(
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
let configuration = client.configuration();
|
||||
let (pull, comments) = tokio::join!(
|
||||
let (pull, comments, files) = tokio::join!(
|
||||
apis::repository_api::repo_get_pull_request(
|
||||
&configuration,
|
||||
&owner,
|
||||
@@ -817,10 +875,12 @@ fn fetch_pull(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
load_pull_files(&configuration, &owner, &repository, number,),
|
||||
);
|
||||
Ok::<_, String>(PullDetails {
|
||||
pull: pull.map_err(|error| error.to_string())?,
|
||||
comments: comments.map_err(|error| error.to_string())?,
|
||||
files: files.map_err(|error| error.to_string())?,
|
||||
})
|
||||
}
|
||||
.await;
|
||||
@@ -847,8 +907,18 @@ fn open_commit_history(
|
||||
) {
|
||||
let server = {
|
||||
let mut state = state.lock().unwrap();
|
||||
let default_branch = state
|
||||
.repositories
|
||||
.iter()
|
||||
.find(|candidate| candidate.owner == owner && candidate.name == repository)
|
||||
.map(|candidate| candidate.default_branch.clone())
|
||||
.unwrap_or_else(|| "main".into());
|
||||
state.active_repository = Some((owner.clone(), repository.clone()));
|
||||
state.active_branch = Some(default_branch.clone());
|
||||
state.branches.clear();
|
||||
state.commits.clear();
|
||||
ui.set_branch_choices(model(vec!["All".into(), default_branch.into()]));
|
||||
ui.set_branch_index(1);
|
||||
state
|
||||
.active_server
|
||||
.and_then(|index| state.preferences.servers.get(index))
|
||||
@@ -876,8 +946,29 @@ fn fetch_commits(
|
||||
ui.set_loading(true);
|
||||
}
|
||||
let weak = ui.as_weak();
|
||||
let (active_branch, branches) = {
|
||||
let state = state.lock().unwrap();
|
||||
(state.active_branch.clone(), state.branches.clone())
|
||||
};
|
||||
spawn(async move {
|
||||
let result = load_commits(&server, &owner, &repository).await;
|
||||
let requested_branch = active_branch.clone();
|
||||
let result = async {
|
||||
let default_branch = active_branch
|
||||
.as_deref()
|
||||
.or(branches.first().map(String::as_str))
|
||||
.unwrap_or("main");
|
||||
let branches = if branches.is_empty() {
|
||||
load_branches(&server, &owner, &repository, default_branch).await?
|
||||
} else {
|
||||
branches
|
||||
};
|
||||
let commits = match active_branch.as_deref() {
|
||||
Some(branch) => load_branch_commits(&server, &owner, &repository, branch).await?,
|
||||
None => load_all_commits(&server, &owner, &repository, &branches).await?,
|
||||
};
|
||||
Ok::<_, String>((requested_branch, branches, commits))
|
||||
}
|
||||
.await;
|
||||
post(move || {
|
||||
let Some(ui) = weak.upgrade() else { return };
|
||||
if refreshing {
|
||||
@@ -886,9 +977,43 @@ fn fetch_commits(
|
||||
ui.set_loading(false);
|
||||
}
|
||||
match result {
|
||||
Ok(commits) => {
|
||||
state.lock().unwrap().commits = commits;
|
||||
refresh_commits(&ui, &state.lock().unwrap().commits);
|
||||
Ok((requested_branch, branches, commits)) => {
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.active_branch != requested_branch {
|
||||
return;
|
||||
}
|
||||
state.branches = branches;
|
||||
state.commits = commits;
|
||||
let mut choices: Vec<slint::SharedString> = vec!["All".into()];
|
||||
choices.extend(state.branches.iter().map(Into::into));
|
||||
ui.set_branch_choices(model(choices));
|
||||
ui.set_branch_index(
|
||||
state
|
||||
.active_branch
|
||||
.as_ref()
|
||||
.and_then(|active| {
|
||||
state.branches.iter().position(|branch| branch == active)
|
||||
})
|
||||
.map_or(0, |index| index as i32 + 1),
|
||||
);
|
||||
let lane_count = if state.active_branch.is_none() {
|
||||
state
|
||||
.commits
|
||||
.iter()
|
||||
.flat_map(|commit| {
|
||||
commit
|
||||
.top_lanes
|
||||
.iter()
|
||||
.chain(commit.bottom_lanes.iter())
|
||||
.chain(commit.connections.iter())
|
||||
.chain(commit.node_lane.iter())
|
||||
})
|
||||
.max()
|
||||
.map_or(0, |lane| lane + 1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
refresh_commits(&ui, &state.commits, lane_count);
|
||||
}
|
||||
Err(error) => ui.set_error(error.into()),
|
||||
}
|
||||
@@ -972,28 +1097,68 @@ fn fetch_file_diff(
|
||||
let Some(ui) = weak.upgrade() else { return };
|
||||
ui.set_loading(false);
|
||||
match result {
|
||||
Ok(diff) => {
|
||||
ui.set_detail_title(path.rsplit('/').next().unwrap_or(&path).into());
|
||||
ui.set_diff_columns(diff.columns.min(i32::MAX as usize) as i32);
|
||||
ui.set_diff_lines(model(
|
||||
diff.lines
|
||||
.into_iter()
|
||||
.map(|line| DiffLine {
|
||||
old_number: line.old_number.into(),
|
||||
new_number: line.new_number.into(),
|
||||
text: line.text.into(),
|
||||
kind: line.kind.into(),
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
ui.set_page("diff".into());
|
||||
}
|
||||
Ok(diff) => show_file_diff(&ui, &path, diff),
|
||||
Err(error) => ui.set_error(error.into()),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn fetch_pull_file_diff(
|
||||
ui: &AppWindow,
|
||||
server: Server,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
path: String,
|
||||
) {
|
||||
ui.set_loading(true);
|
||||
let weak = ui.as_weak();
|
||||
spawn(async move {
|
||||
let result = async {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
apis::repository_api::repo_download_pull_diff_or_patch(
|
||||
&client.configuration(),
|
||||
&owner,
|
||||
&repository,
|
||||
number,
|
||||
"diff",
|
||||
Some(false),
|
||||
)
|
||||
.await
|
||||
.map(|text| diff::parse_file(&text, &path))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
.await;
|
||||
post(move || {
|
||||
let Some(ui) = weak.upgrade() else { return };
|
||||
ui.set_loading(false);
|
||||
match result {
|
||||
Ok(diff) => show_file_diff(&ui, &path, diff),
|
||||
Err(error) => ui.set_error(error.into()),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn show_file_diff(ui: &AppWindow, path: &str, diff: diff::Parsed) {
|
||||
ui.set_detail_title(path.rsplit('/').next().unwrap_or(path).into());
|
||||
ui.set_diff_columns(diff.columns.min(i32::MAX as usize) as i32);
|
||||
ui.set_diff_lines(model(
|
||||
diff.lines
|
||||
.into_iter()
|
||||
.map(|line| DiffLine {
|
||||
old_number: line.old_number.into(),
|
||||
new_number: line.new_number.into(),
|
||||
text: line.text.into(),
|
||||
kind: line.kind.into(),
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
ui.set_page("diff".into());
|
||||
}
|
||||
|
||||
fn runtime() -> &'static tokio::runtime::Runtime {
|
||||
RUNTIME.get_or_init(|| tokio::runtime::Runtime::new().expect("Tokio runtime"))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
slint::slint! {
|
||||
import { Button, LineEdit, ListView, ScrollView, Spinner } from "std-widgets.slint";
|
||||
import { Button, ComboBox, LineEdit, ListView, ScrollView, Spinner } from "std-widgets.slint";
|
||||
|
||||
export struct ServerRow { name: string, url: string }
|
||||
export struct RepositoryRow {
|
||||
@@ -14,7 +14,10 @@ slint::slint! {
|
||||
}
|
||||
export struct MarkdownBlock { text: styled-text, kind: string }
|
||||
export struct CommentRow { author: string, body: [MarkdownBlock], meta: string }
|
||||
export struct CommitRow { sha: string, title: string, meta: string }
|
||||
export struct GraphLane { color: color, top: bool, bottom: bool, node: bool, connect_left: bool }
|
||||
export struct CommitRow {
|
||||
sha: string, title: string, meta: string, refs: string, lanes: [GraphLane],
|
||||
}
|
||||
export struct FileRow { path: string, status: string }
|
||||
export struct ActivityRow {
|
||||
icon: string, title: string, detail: string, meta: string,
|
||||
@@ -113,6 +116,19 @@ slint::slint! {
|
||||
}
|
||||
}
|
||||
|
||||
component FileCard inherits Rectangle {
|
||||
in property <FileRow> file;
|
||||
callback clicked();
|
||||
height: 66px;
|
||||
background: #ffffff;
|
||||
border-color: #dedee3;
|
||||
border-width: 1px;
|
||||
border-radius: 10px;
|
||||
TouchArea { clicked => { root.clicked(); } }
|
||||
Text { x: 13px; y: 10px; width: parent.width - 26px; text: root.file.path; font-size: 14px; font-weight: 600; color: #232327; overflow: elide; }
|
||||
Text { x: 13px; y: 38px; width: parent.width - 26px; text: root.file.status; font-size: 12px; color: #74747a; }
|
||||
}
|
||||
|
||||
component TabButton inherits Rectangle {
|
||||
in property <string> icon;
|
||||
in property <string> label;
|
||||
@@ -175,11 +191,14 @@ slint::slint! {
|
||||
in-out property <[MarkdownBlock]> issue_body;
|
||||
in-out property <string> detail_title;
|
||||
in-out property <string> detail_meta;
|
||||
in-out property <string> detail_files_ref;
|
||||
in-out property <[MarkdownBlock]> detail_body;
|
||||
in-out property <[DiffLine]> diff_lines;
|
||||
in-out property <int> diff_columns;
|
||||
in-out property <string> issue_filter: "open";
|
||||
in-out property <string> pull_filter: "open";
|
||||
in-out property <[string]> branch_choices;
|
||||
in-out property <int> branch_index;
|
||||
in-out property <bool> filter_open;
|
||||
in-out property <bool> loading;
|
||||
in-out property <bool> home_loading;
|
||||
@@ -211,7 +230,9 @@ slint::slint! {
|
||||
callback select_issue(int);
|
||||
callback select_pull(string, string, int);
|
||||
callback select_commit(string);
|
||||
callback select_branch(string);
|
||||
callback select_file(string);
|
||||
callback select_pull_file(string);
|
||||
callback select_activity(string, string, string, int, string);
|
||||
callback choose_filter(string);
|
||||
callback back();
|
||||
@@ -535,6 +556,8 @@ slint::slint! {
|
||||
Text { text: root.detail_meta; font-size: 13px; color: #707077; wrap: word-wrap; }
|
||||
Rectangle { height: 1px; background: #d8d8dd; }
|
||||
MarkdownContent { width: parent.width; blocks: root.detail_body; }
|
||||
if root.files.length > 0: Text { text: root.detail_files_ref; font-size: 17px; font-weight: 600; color: #252529; }
|
||||
for file in root.files: FileCard { width: parent.width; file: file; clicked => { root.select_pull_file(file.path); } }
|
||||
if root.comments.length > 0: Text { text: "Comments"; font-size: 17px; font-weight: 600; color: #252529; }
|
||||
for comment in root.comments: CommentCard { width: parent.width; comment: comment; }
|
||||
}
|
||||
@@ -549,6 +572,11 @@ slint::slint! {
|
||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px;
|
||||
background: #f2f2f7;
|
||||
Header { x: 0; y: 0; title: root.repository_title; can_back: true; back => { root.back(); } }
|
||||
ComboBox {
|
||||
x: 16px; y: 66px; width: parent.width - 32px; height: 42px;
|
||||
model: root.branch_choices; current-index: root.branch_index;
|
||||
selected(value) => { root.select_branch(value); }
|
||||
}
|
||||
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
|
||||
if !root.loading && root.commits.length == 0: EmptyState {
|
||||
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
|
||||
@@ -556,18 +584,59 @@ slint::slint! {
|
||||
detail: "This repository has no commit history.";
|
||||
}
|
||||
PullToRefreshGesture {
|
||||
x: 16px; y: 70px; width: parent.width - 32px; height: parent.height - 82px;
|
||||
x: 16px; y: 116px; width: parent.width - 32px; height: parent.height - 128px;
|
||||
at_top: root.repositories_at_top; refreshing: root.refreshing;
|
||||
refresh => { root.refresh_commits(); }
|
||||
ListView {
|
||||
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
||||
scrolled => { root.repositories_at_top = self.viewport-y >= 0px; }
|
||||
for commit in root.commits: Rectangle {
|
||||
height: 86px; background: #ffffff; border-color: #dedee3; border-width: 1px; border-radius: 12px;
|
||||
for commit in root.commits: commit_row := Rectangle {
|
||||
property <length> graph-width: commit.lanes.length > 0 ? min(72px, commit.lanes.length * 10px + 8px) : 0px;
|
||||
property <length> lane-spacing: (self.graph-width - 8px) / max(1, commit.lanes.length);
|
||||
height: 86px; background: transparent;
|
||||
Rectangle { width: 100%; height: 100%; background: #ffffff; border-color: #dedee3; border-width: 1px; border-radius: 12px; }
|
||||
TouchArea { clicked => { root.select_commit(commit.sha); } }
|
||||
Text { x: 14px; y: 11px; width: parent.width - 28px; text: commit.title; font-size: 15px; font-weight: 600; color: #17171a; overflow: elide; }
|
||||
Text { x: 14px; y: 40px; width: parent.width - 28px; text: commit.meta; font-size: 12px; color: #707077; overflow: elide; }
|
||||
Text { x: 14px; y: 61px; width: parent.width - 28px; text: commit.sha; font-size: 11px; color: #919198; overflow: elide; }
|
||||
for lane[index] in commit.lanes: Rectangle {
|
||||
x: 4px + index * commit_row.lane-spacing;
|
||||
width: 3px; height: 100%; background: transparent;
|
||||
if lane.top: Rectangle {
|
||||
y: -1px; width: 3px;
|
||||
height: lane.connect_left && !lane.bottom ? 37px : 42px;
|
||||
background: lane.color;
|
||||
}
|
||||
if lane.bottom: Rectangle {
|
||||
y: lane.connect_left && !lane.top ? 45px : 40px;
|
||||
width: 3px;
|
||||
height: parent.height - (lane.connect_left && !lane.top ? 44px : 39px);
|
||||
background: lane.color;
|
||||
}
|
||||
if lane.connect_left && lane.top == lane.bottom: Rectangle {
|
||||
x: -commit_row.lane-spacing; y: 39px;
|
||||
width: commit_row.lane-spacing + 3px; height: 3px;
|
||||
background: lane.color;
|
||||
}
|
||||
if lane.connect_left && lane.bottom && !lane.top: Path {
|
||||
x: 1.5px - commit_row.lane-spacing; y: 35px;
|
||||
width: commit_row.lane-spacing; height: 11px;
|
||||
viewbox-width: 10; viewbox-height: 11;
|
||||
commands: "M 0 5.5 L 5 5.5 Q 10 5.5 10 10.5";
|
||||
fill: transparent; stroke: lane.color; stroke-width: 3px; stroke-line-cap: round;
|
||||
}
|
||||
if lane.connect_left && lane.top && !lane.bottom: Path {
|
||||
x: 1.5px - commit_row.lane-spacing; y: 35px;
|
||||
width: commit_row.lane-spacing; height: 11px;
|
||||
viewbox-width: 10; viewbox-height: 11;
|
||||
commands: "M 10 0.5 Q 10 5.5 5 5.5 L 0 5.5";
|
||||
fill: transparent; stroke: lane.color; stroke-width: 3px; stroke-line-cap: round;
|
||||
}
|
||||
if lane.node: Rectangle {
|
||||
x: -3px; y: 36px; width: 9px; height: 9px; border-radius: 5px;
|
||||
background: lane.color; border-color: #ffffff; border-width: 2px;
|
||||
}
|
||||
}
|
||||
Text { x: parent.graph-width + 14px; y: 8px; width: parent.width - self.x - 14px; text: commit.title; font-size: 15px; font-weight: 600; color: #17171a; overflow: elide; }
|
||||
Text { x: parent.graph-width + 14px; y: 33px; width: parent.width - self.x - 14px; text: commit.refs != "" ? commit.refs : commit.meta; font-size: 12px; color: commit.refs != "" ? #0879e1 : #707077; overflow: elide; }
|
||||
Text { x: parent.graph-width + 14px; y: 53px; width: parent.width - self.x - 14px; text: commit.refs != "" ? commit.meta + " · " + commit.sha : commit.sha; font-size: 11px; color: #919198; overflow: elide; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,17 +656,12 @@ slint::slint! {
|
||||
}
|
||||
ListView {
|
||||
x: 16px; y: 70px; width: parent.width - 32px; height: parent.height - 82px; mouse-drag-pan-enabled: true;
|
||||
for file in root.files: Rectangle {
|
||||
height: 66px; background: #ffffff; border-color: #dedee3; border-width: 1px; border-radius: 10px;
|
||||
TouchArea { clicked => { root.select_file(file.path); } }
|
||||
Text { x: 13px; y: 10px; width: parent.width - 26px; text: file.path; font-size: 14px; font-weight: 600; color: #232327; overflow: elide; }
|
||||
Text { x: 13px; y: 38px; width: parent.width - 26px; text: file.status; font-size: 12px; color: #74747a; }
|
||||
}
|
||||
for file in root.files: FileCard { file: file; clicked => { root.select_file(file.path); } }
|
||||
}
|
||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||
}
|
||||
|
||||
if tab == "repositories" && page == "diff": Rectangle {
|
||||
if (tab == "repositories" || tab == "pulls") && page == "diff": Rectangle {
|
||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px;
|
||||
|
||||
@@ -3,7 +3,7 @@ use slint::Color;
|
||||
|
||||
use crate::{
|
||||
activity,
|
||||
domain::{HomeData, IssueDetails, Preferences, PullDetails, State},
|
||||
domain::{HistoryCommit, HomeData, IssueDetails, Preferences, PullDetails, State},
|
||||
markdown, model,
|
||||
storage::favorite_key,
|
||||
ui::*,
|
||||
@@ -149,11 +149,12 @@ pub fn refresh_pulls(ui: &AppWindow, pulls: &[models::Issue]) {
|
||||
));
|
||||
}
|
||||
|
||||
pub fn refresh_commits(ui: &AppWindow, commits: &[models::Commit]) {
|
||||
pub fn refresh_commits(ui: &AppWindow, commits: &[HistoryCommit], lane_count: usize) {
|
||||
ui.set_commits(model(
|
||||
commits
|
||||
.iter()
|
||||
.filter_map(|commit| {
|
||||
.filter_map(|row| {
|
||||
let commit = &row.commit;
|
||||
let sha = commit.sha.as_deref()?;
|
||||
let details = commit.commit.as_ref();
|
||||
let author = details
|
||||
@@ -178,6 +179,23 @@ pub fn refresh_commits(ui: &AppWindow, commits: &[models::Commit]) {
|
||||
.unwrap_or("Commit")
|
||||
.into(),
|
||||
meta: format!("{author} · {}", compact_date(date)).into(),
|
||||
refs: row.refs.join(" · ").into(),
|
||||
lanes: model(
|
||||
(0..lane_count)
|
||||
.map(|lane| GraphLane {
|
||||
color: Color::from_hsva(
|
||||
(lane as f32 * 137.508) % 360.0,
|
||||
0.78,
|
||||
0.78,
|
||||
1.0,
|
||||
),
|
||||
top: row.top_lanes.contains(&lane),
|
||||
bottom: row.bottom_lanes.contains(&lane),
|
||||
node: row.node_lane == Some(lane),
|
||||
connect_left: row.connections.contains(&lane),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
@@ -265,6 +283,9 @@ fn activity_row(activity: &models::Activity) -> ActivityRow {
|
||||
};
|
||||
let target = activity::target(activity);
|
||||
let (target, owner, repository, number, sha) = match target {
|
||||
Some(activity::Target::Repository { owner, repository }) => {
|
||||
("repository", owner, repository, 0, String::new())
|
||||
}
|
||||
Some(activity::Target::Issue {
|
||||
owner,
|
||||
repository,
|
||||
@@ -430,9 +451,41 @@ pub fn show_pull(ui: &AppWindow, details: &PullDetails) {
|
||||
.unwrap_or("No description provided."),
|
||||
)));
|
||||
ui.set_comments(model(details.comments.iter().map(comment_row).collect()));
|
||||
ui.set_files(model(
|
||||
details
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|file| {
|
||||
Some(FileRow {
|
||||
path: file.filename.clone()?.into(),
|
||||
status: file
|
||||
.status
|
||||
.clone()
|
||||
.unwrap_or_else(|| "modified".into())
|
||||
.into(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
ui.set_detail_files_ref(pull_files_ref(pull).into());
|
||||
ui.set_page("pull".into());
|
||||
}
|
||||
|
||||
fn pull_files_ref(pull: &models::PullRequest) -> String {
|
||||
if pull.state.as_deref() == Some("open") {
|
||||
let branch = pull
|
||||
.head
|
||||
.as_ref()
|
||||
.and_then(|head| head.r#ref.as_deref().or(head.label.as_deref()))
|
||||
.unwrap_or("head branch");
|
||||
format!("Files on {branch}")
|
||||
} else if let Some(sha) = pull.merge_commit_sha.as_deref() {
|
||||
format!("Files at merge commit {}", &sha[..sha.len().min(8)])
|
||||
} else {
|
||||
"Files from pull request".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn comment_row(comment: &models::Comment) -> CommentRow {
|
||||
CommentRow {
|
||||
author: comment
|
||||
@@ -515,4 +568,21 @@ mod tests {
|
||||
assert!(label_row(&label).is_some());
|
||||
assert!(label_row(&models::Label::default()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_files_use_the_live_branch_or_merge_commit() {
|
||||
let mut pull = models::PullRequest {
|
||||
state: Some("open".into()),
|
||||
head: Some(Box::new(models::PrBranchInfo {
|
||||
r#ref: Some("feature/files".into()),
|
||||
..Default::default()
|
||||
})),
|
||||
merge_commit_sha: Some("0123456789abcdef".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(pull_files_ref(&pull), "Files on feature/files");
|
||||
|
||||
pull.state = Some("closed".into());
|
||||
assert_eq!(pull_files_ref(&pull), "Files at merge commit 01234567");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user