From 8c383e3672af2a3c9c8ef4efe47ba2da265cb67b Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Thu, 30 Jul 2026 17:40:34 +0200 Subject: [PATCH] feat: improve repository history navigation --- crates/app/src/activity.rs | 15 ++ crates/app/src/api.rs | 418 +++++++++++++++++++++++++++++++++++-- crates/app/src/domain.rs | 16 +- crates/app/src/main.rs | 207 ++++++++++++++++-- crates/app/src/ui.rs | 94 +++++++-- crates/app/src/view.rs | 76 ++++++- 6 files changed, 767 insertions(+), 59 deletions(-) diff --git a/crates/app/src/activity.rs b/crates/app/src/activity.rs index 6c77ea6..c8aae91 100644 --- a/crates/app/src/activity.rs +++ b/crates/app/src/activity.rs @@ -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 { .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 { diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index a6c0ab7..b20db62 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -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, 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 Result, 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, 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, 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, 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>, String> { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + let configuration = client.configuration(); + let mut refs: HashMap> = 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)>, + mut extra_refs: HashMap>, +) -> Vec { + let mut commits = HashMap::new(); + let mut ranks: HashMap = HashMap::new(); + let mut refs: HashMap> = 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> = 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>, 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) { + for lane in left.min(right) + 1..=left.max(right) { + connections.insert(lane); + } +} + +fn commit_parents(commit: &models::Commit) -> impl Iterator { + 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, 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 { @@ -168,3 +487,64 @@ pub async fn load_home(server: &Server) -> Result { 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::>(), + ["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)); + } +} diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index cdf795a..0f405cc 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -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, + pub bottom_lanes: Vec, + pub node_lane: Option, + pub connections: Vec, + pub refs: Vec, } #[derive(Default)] @@ -59,7 +70,9 @@ pub struct State { pub repositories: Vec, pub issues: Vec, pub pulls: Vec, - pub commits: Vec, + pub branches: Vec, + pub active_branch: Option, + pub commits: Vec, } pub struct HomeData { @@ -75,6 +88,7 @@ pub struct IssueDetails { pub struct PullDetails { pub pull: models::PullRequest, pub comments: Vec, + pub files: Vec, } pub fn open_status() -> String { diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index bd14492..f03a17a 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -390,6 +390,34 @@ fn wire_callbacks(ui: &AppWindow, state: Arc>) { } }); + 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>) { } }); + 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>) { 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>) { } 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 = 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")) } diff --git a/crates/app/src/ui.rs b/crates/app/src/ui.rs index b3f3c68..e336150 100644 --- a/crates/app/src/ui.rs +++ b/crates/app/src/ui.rs @@ -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 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 icon; in property label; @@ -175,11 +191,14 @@ slint::slint! { in-out property <[MarkdownBlock]> issue_body; in-out property detail_title; in-out property detail_meta; + in-out property detail_files_ref; in-out property <[MarkdownBlock]> detail_body; in-out property <[DiffLine]> diff_lines; in-out property diff_columns; in-out property issue_filter: "open"; in-out property pull_filter: "open"; + in-out property <[string]> branch_choices; + in-out property branch_index; in-out property filter_open; in-out property loading; in-out property 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 graph-width: commit.lanes.length > 0 ? min(72px, commit.lanes.length * 10px + 8px) : 0px; + property 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; diff --git a/crates/app/src/view.rs b/crates/app/src/view.rs index 61c2eca..155d7d0 100644 --- a/crates/app/src/view.rs +++ b/crates/app/src/view.rs @@ -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"); + } }