Add activity dashboard and mobile gestures

This commit is contained in:
Georg Bauer
2026-07-30 14:35:38 +02:00
parent 6a0c5d7b4c
commit 6fcfdb9e5b

View File

@@ -9,7 +9,7 @@ use std::{
use gotcha_gitea::{Client, apis, models};
use security_framework::passwords::{get_generic_password, set_generic_password};
use serde::{Deserialize, Serialize};
use slint::{ModelRc, VecModel};
use slint::{Color, ModelRc, VecModel};
slint::slint! {
import { Button, LineEdit, ListView, ScrollView, Spinner } from "std-widgets.slint";
@@ -18,9 +18,12 @@ slint::slint! {
export struct RepositoryRow {
name: string, owner: string, description: string, meta: string, favorite: bool,
}
export struct LabelRow { name: string, background: color, foreground: color }
export struct IssueRow {
number: int, title: string, summary: string, meta: string,
number: int, title: string, summary: string, meta: string, labels: [LabelRow],
}
export struct ActivityRow { icon: string, title: string, detail: string, meta: string }
export struct HeatCell { week: int, day: int, level: int }
component Header inherits Rectangle {
in property <string> title;
@@ -65,24 +68,81 @@ slint::slint! {
Text { text: root.detail; font-size: 15px; horizontal-alignment: center; wrap: word-wrap; color: #69696f; }
}
component TabButton inherits Rectangle {
in property <string> icon;
in property <string> label;
in property <bool> active;
callback clicked();
background: transparent;
TouchArea { clicked => { root.clicked(); } }
Text {
y: 4px; height: 29px; width: 100%; text: root.icon;
color: root.active ? #0879e1 : #77777d; font-size: 24px;
horizontal-alignment: center; vertical-alignment: center;
}
Text {
y: 34px; height: 18px; width: 100%; text: root.label;
color: root.active ? #0879e1 : #77777d; font-size: 10px;
font-weight: root.active ? 600 : 400;
horizontal-alignment: center; vertical-alignment: center;
}
}
component PullToRefreshGesture inherits SwipeGestureHandler {
in property <bool> at_top;
in property <bool> refreshing;
callback refresh();
enabled: root.at_top && !root.refreshing;
handle-swipe-down: true;
swiped => { root.refresh(); }
@children
if self.swiping: Rectangle {
x: (parent.width - 142px) / 2; y: 8px; width: 142px; height: 32px;
background: #e9e9ee; border-radius: 16px;
Text { text: "Release to refresh"; font-size: 12px; color: #55555b; horizontal-alignment: center; vertical-alignment: center; }
}
}
export component AppWindow inherits Window {
title: "Gotcha";
preferred-width: 390px;
preferred-height: 844px;
background: #f2f2f7;
in-out property <string> tab: "home";
in-out property <string> page: "servers";
in-out property <[ServerRow]> servers;
in-out property <[RepositoryRow]> repositories;
in-out property <[IssueRow]> issues;
in-out property <[ActivityRow]> activities;
in-out property <[HeatCell]> heat_cells;
in-out property <int> contribution_count;
in-out property <bool> has_active_server;
in-out property <string> home_server;
in-out property <string> server_title;
in-out property <string> repository_title;
in-out property <string> issue_title;
in-out property <string> issue_meta;
in-out property <string> issue_body;
in-out property <bool> loading;
in-out property <bool> home_loading;
in-out property <bool> refreshing;
in-out property <string> error;
in-out property <int> home_scroll_request;
in-out property <int> servers_scroll_request;
in-out property <int> repositories_scroll_request;
in-out property <int> issues_scroll_request;
in-out property <int> issue_scroll_request;
private property <bool> home_at_top: true;
private property <bool> repositories_at_top: true;
private property <bool> issues_at_top: true;
private property <bool> issue_at_top: true;
callback select_tab(string);
callback refresh_home();
callback refresh_repositories();
callback refresh_issues();
callback refresh_issue();
callback show_add_server();
callback save_server(string, string, string);
callback select_server(int);
@@ -91,14 +151,91 @@ slint::slint! {
callback select_issue(int);
callback back();
if page == "servers": Rectangle {
if tab == "home": 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;
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px;
background: #f2f2f7;
Header { x: 0; y: 0; title: root.has_active_server ? root.home_server : "Home"; can_back: false; }
if !root.has_active_server: EmptyState {
x: 32px; width: parent.width - 64px; y: 210px; height: 190px;
title: "No server selected";
detail: "Open Issues to select a server or add your first one.";
}
if root.has_active_server: Rectangle {
x: 16px; y: 72px; width: parent.width - 32px; height: 118px;
background: #ffffff; border-radius: 12px;
Text { x: 13px; y: 10px; text: "Activity · last 12 months"; font-size: 13px; font-weight: 600; color: #38383d; }
Text { x: 13px; y: 86px; text: root.contribution_count + " contributions"; font-size: 12px; color: #74747a; }
for cell in root.heat_cells: Rectangle {
x: 13px + cell.week * 6.7px; y: 39px + cell.day * 6.2px;
width: 5.2px; height: 5.2px; border-radius: 1px;
background: cell.level == 0 ? #e5e5e9 : cell.level == 1 ? #b8d9f4 : cell.level == 2 ? #72b5e8 : cell.level == 3 ? #278bd4 : #0969b7;
}
}
if root.has_active_server && !root.home_loading && root.activities.length == 0: EmptyState {
x: 32px; width: parent.width - 64px; y: 250px; height: 150px;
title: "No recent activity";
detail: "Your recent server actions will appear here.";
}
if root.has_active_server: PullToRefreshGesture {
x: 16px; y: 202px; width: parent.width - 32px; height: parent.height - 214px;
at_top: root.home_at_top; refreshing: root.home_loading;
refresh => { root.refresh_home(); }
home_list := ListView {
property <int> scroll_request: root.home_scroll_request;
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
changed scroll_request => { self.viewport-y = 0px; root.home_at_top = true; }
scrolled => { root.home_at_top = self.viewport-y >= 0px; }
for activity in root.activities: Rectangle {
height: 94px;
background: #ffffff; border-radius: 12px;
Rectangle {
x: 12px; y: 14px; width: 34px; height: 34px; border-radius: 17px;
background: #e5f2fd;
Text { text: activity.icon; color: #0879e1; font-size: 20px; horizontal-alignment: center; vertical-alignment: center; }
}
Text { x: 57px; y: 11px; width: parent.width - 69px; height: 24px; text: activity.title; font-size: 15px; font-weight: 600; color: #19191d; overflow: elide; }
Text { x: 57px; y: 36px; width: parent.width - 69px; height: 35px; text: activity.detail; font-size: 13px; color: #55555c; wrap: word-wrap; overflow: elide; }
Text { x: 57px; y: 72px; width: parent.width - 69px; text: activity.meta; font-size: 11px; color: #898990; overflow: elide; }
}
}
}
if root.home_loading: Spinner { width: 34px; height: 34px; x: (parent.width - self.width) / 2; y: 222px; }
}
if tab == "repositories" || tab == "notifications" || tab == "settings": 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;
background: #f2f2f7;
Header {
x: 0; y: 0; can_back: false;
title: root.tab == "repositories" ? "Repositories" : root.tab == "notifications" ? "Notifications" : "Settings";
}
EmptyState {
x: 32px; width: parent.width - 64px; y: 220px; height: 160px;
title: "Coming soon";
detail: "This section is ready for the next feature pass.";
}
}
if tab == "issues" && page == "servers": 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;
background: #f2f2f7;
Header { x: 0; y: 0; title: "Servers"; can_back: false; }
if root.servers.length > 0: ListView {
if root.servers.length > 0: servers_list := ListView {
property <int> scroll_request: root.servers_scroll_request;
x: 16px; y: 76px; width: parent.width - 32px; height: parent.height - 154px;
mouse-drag-pan-enabled: true;
changed scroll_request => { self.viewport-y = 0px; }
for server[index] in root.servers: Rectangle {
height: 76px;
background: #ffffff;
@@ -114,10 +251,10 @@ slint::slint! {
Button { x: 16px; y: parent.height - 66px; width: parent.width - 32px; height: 48px; text: "Add Server"; clicked => { root.show_add_server(); } }
}
if page == "add": Rectangle {
if tab == "issues" && page == "add": 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;
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px;
background: #f2f2f7;
Header { x: 0; y: 0; title: "Add Server"; can_back: true; back => { root.back(); } }
VerticalLayout {
@@ -138,96 +275,162 @@ slint::slint! {
EdgeBack { x: 0; y: 0; back => { root.back(); } }
}
if page == "repositories": Rectangle {
if tab == "issues" && page == "repositories": 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;
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px;
background: #f2f2f7;
Header { x: 0; y: 0; title: root.server_title; can_back: true; back => { root.back(); } }
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
if !root.loading && root.repositories.length == 0: EmptyState {
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
title: "No repositories";
detail: "This account does not own any repositories on this server.";
}
ListView {
PullToRefreshGesture {
x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px;
for repo in root.repositories: Rectangle {
height: 104px;
background: #ffffff;
border-color: #dedee3;
border-width: 1px;
border-radius: 12px;
TouchArea { clicked => { root.select_repository(repo.owner, repo.name); } }
Text { x: 15px; y: 11px; width: parent.width - 62px; text: repo.name; font-size: 17px; font-weight: 600; color: #17171a; overflow: elide; }
Text { x: 15px; y: 39px; width: parent.width - 30px; text: repo.description; font-size: 14px; color: #56565c; overflow: elide; }
Text { x: 15px; y: 71px; width: parent.width - 30px; text: repo.meta; font-size: 12px; color: #7b7b82; overflow: elide; }
TouchArea {
x: parent.width - 52px; y: 2px; width: 48px; height: 48px;
clicked => { root.toggle_favorite(repo.owner, repo.name); }
Text { text: repo.favorite ? "" : ""; color: repo.favorite ? #f2a900 : #888890; font-size: 27px; horizontal-alignment: center; vertical-alignment: center; }
at_top: root.repositories_at_top; refreshing: root.refreshing;
refresh => { root.refresh_repositories(); }
repositories_list := ListView {
property <int> scroll_request: root.repositories_scroll_request;
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
changed scroll_request => { self.viewport-y = 0px; root.repositories_at_top = true; }
scrolled => { root.repositories_at_top = self.viewport-y >= 0px; }
for repo in root.repositories: Rectangle {
height: 104px;
background: #ffffff;
border-color: #dedee3;
border-width: 1px;
border-radius: 12px;
TouchArea { clicked => { root.select_repository(repo.owner, repo.name); } }
Text { x: 15px; y: 11px; width: parent.width - 62px; text: repo.name; font-size: 17px; font-weight: 600; color: #17171a; overflow: elide; }
Text { x: 15px; y: 39px; width: parent.width - 30px; text: repo.description; font-size: 14px; color: #56565c; overflow: elide; }
Text { x: 15px; y: 71px; width: parent.width - 30px; text: repo.meta; font-size: 12px; color: #7b7b82; overflow: elide; }
TouchArea {
x: parent.width - 52px; y: 2px; width: 48px; height: 48px;
clicked => { root.toggle_favorite(repo.owner, repo.name); }
Text { text: repo.favorite ? "" : ""; color: repo.favorite ? #f2a900 : #888890; font-size: 27px; horizontal-alignment: center; vertical-alignment: center; }
}
}
}
}
EdgeBack { x: 0; y: 0; back => { root.back(); } }
}
if page == "issues": Rectangle {
if tab == "issues" && page == "issues": 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;
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(); } }
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
if !root.loading && root.issues.length == 0: EmptyState {
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
title: "No open issues";
detail: "Everything is clear for this repository.";
}
ListView {
PullToRefreshGesture {
x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px;
for issue in root.issues: Rectangle {
height: 112px;
background: #ffffff;
border-color: #dedee3;
border-width: 1px;
border-radius: 12px;
TouchArea { clicked => { root.select_issue(issue.number); } }
Text { x: 15px; y: 11px; width: parent.width - 44px; text: issue.title; font-size: 16px; font-weight: 600; color: #17171a; overflow: elide; }
Text { x: 15px; y: 39px; width: parent.width - 30px; height: 38px; text: issue.summary; font-size: 13px; color: #56565c; wrap: word-wrap; overflow: elide; }
Text { x: 15px; y: 86px; width: parent.width - 30px; text: issue.meta; font-size: 12px; color: #7b7b82; overflow: elide; }
at_top: root.issues_at_top; refreshing: root.refreshing;
refresh => { root.refresh_issues(); }
issues_list := ListView {
property <int> scroll_request: root.issues_scroll_request;
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
changed scroll_request => { self.viewport-y = 0px; root.issues_at_top = true; }
scrolled => { root.issues_at_top = self.viewport-y >= 0px; }
for issue in root.issues: Rectangle {
height: issue.labels.length > 0 ? 138px : 112px;
background: #ffffff;
border-color: #dedee3;
border-width: 1px;
border-radius: 12px;
TouchArea { clicked => { root.select_issue(issue.number); } }
Text { x: 15px; y: 11px; width: parent.width - 44px; text: issue.title; font-size: 16px; font-weight: 600; color: #17171a; overflow: elide; }
Text { x: 15px; y: 39px; width: parent.width - 30px; height: 38px; text: issue.summary; font-size: 13px; color: #56565c; wrap: word-wrap; overflow: elide; }
if issue.labels.length > 0: HorizontalLayout {
x: 15px; y: 82px; width: parent.width - 30px; height: 22px; spacing: 5px;
for label in issue.labels: Rectangle {
width: min(104px, max(48px, (parent.width - (issue.labels.length - 1) * 5px) / issue.labels.length));
height: 22px; background: label.background; border-radius: 11px;
Text { x: 7px; width: parent.width - 14px; text: label.name; color: label.foreground; font-size: 11px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; overflow: elide; }
}
}
Text { x: 15px; y: issue.labels.length > 0 ? 112px : 86px; width: parent.width - 30px; text: issue.meta; font-size: 12px; color: #7b7b82; overflow: elide; }
}
}
}
EdgeBack { x: 0; y: 0; back => { root.back(); } }
}
if page == "issue": Rectangle {
if tab == "issues" && page == "issue": 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;
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px;
background: #f2f2f7;
Header { x: 0; y: 0; title: "Issue"; can_back: true; back => { root.back(); } }
ScrollView {
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
PullToRefreshGesture {
x: 18px; y: 78px; width: parent.width - 36px; height: parent.height - 96px;
VerticalLayout {
width: parent.width;
spacing: 12px;
alignment: start;
Text { text: root.issue_title; font-size: 23px; font-weight: 650; color: #161619; wrap: word-wrap; }
Text { text: root.issue_meta; font-size: 13px; color: #707077; wrap: word-wrap; }
Rectangle { height: 1px; background: #d8d8dd; }
Text { text: root.issue_body; font-size: 16px; color: #252529; wrap: word-wrap; }
at_top: root.issue_at_top; refreshing: root.refreshing;
refresh => { root.refresh_issue(); }
issue_scroll := ScrollView {
property <int> scroll_request: root.issue_scroll_request;
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
changed scroll_request => { self.viewport-y = 0px; root.issue_at_top = true; }
scrolled => { root.issue_at_top = self.viewport-y >= 0px; }
VerticalLayout {
width: parent.width;
spacing: 12px;
alignment: start;
Text { text: root.issue_title; font-size: 23px; font-weight: 650; color: #161619; wrap: word-wrap; }
Text { text: root.issue_meta; font-size: 13px; color: #707077; wrap: word-wrap; }
Rectangle { height: 1px; background: #d8d8dd; }
Text { text: root.issue_body; font-size: 16px; color: #252529; wrap: word-wrap; }
}
}
}
EdgeBack { x: 0; y: 0; back => { root.back(); } }
}
if root.loading: Rectangle {
Rectangle {
x: root.safe-area-insets.left;
y: root.height - root.safe-area-insets.bottom - 58px;
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
height: 58px;
background: #fbfbfc;
Rectangle { width: 100%; height: 1px; background: #d4d4d8; }
TabButton {
x: 0; width: parent.width / 5; height: parent.height; icon: ""; label: "Home"; active: root.tab == "home";
clicked => {
if root.tab == "home" { root.home_scroll_request += 1; }
root.select_tab("home");
}
}
TabButton {
x: parent.width / 5; width: parent.width / 5; height: parent.height; icon: "!"; label: "Issues"; active: root.tab == "issues";
clicked => {
if root.tab == "issues" {
if root.page == "servers" { root.servers_scroll_request += 1; }
if root.page == "repositories" { root.repositories_scroll_request += 1; }
if root.page == "issues" { root.issues_scroll_request += 1; }
if root.page == "issue" { root.issue_scroll_request += 1; }
}
root.select_tab("issues");
}
}
TabButton { x: parent.width * 2 / 5; width: parent.width / 5; height: parent.height; icon: ""; label: "Repos"; active: root.tab == "repositories"; clicked => { root.select_tab("repositories"); } }
TabButton { x: parent.width * 3 / 5; width: parent.width / 5; height: parent.height; icon: ""; label: "Alerts"; active: root.tab == "notifications"; clicked => { root.select_tab("notifications"); } }
TabButton { x: parent.width * 4 / 5; width: parent.width / 5; height: parent.height; icon: ""; label: "Settings"; active: root.tab == "settings"; clicked => { root.select_tab("settings"); } }
}
if root.loading && root.tab == "issues": Rectangle {
width: 100%; height: 100%;
background: #55ffffff;
TouchArea { }
Spinner { width: 38px; height: 38px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
}
if root.error != "": Rectangle {
x: 14px; y: parent.height - 94px; width: parent.width - 28px; height: 66px;
x: 14px; y: parent.height - root.safe-area-insets.bottom - 132px; width: parent.width - 28px; height: 66px;
background: #d93d35; border-radius: 12px;
Text { x: 14px; width: parent.width - 28px; text: root.error; color: white; font-size: 13px; wrap: word-wrap; vertical-alignment: center; }
TouchArea { clicked => { root.error = ""; } }
@@ -249,6 +452,8 @@ struct Preferences {
servers: Vec<Server>,
#[serde(default)]
favorites: BTreeSet<String>,
#[serde(default)]
last_server: Option<usize>,
}
#[derive(Clone)]
@@ -266,10 +471,16 @@ struct State {
preferences: Preferences,
active_server: Option<usize>,
active_repository: Option<(String, String)>,
active_issue: Option<i64>,
repositories: Vec<RepositoryData>,
issues: Vec<models::Issue>,
}
struct HomeData {
activities: Vec<models::Activity>,
heatmap: Vec<models::UserHeatmapData>,
}
static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -289,9 +500,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
wire_callbacks(&ui, state.clone());
let server_count = state.lock().unwrap().preferences.servers.len();
if server_count == 1 {
open_server(&ui, state, 0);
let startup_server = {
let state = state.lock().unwrap();
state
.preferences
.last_server
.filter(|index| *index < state.preferences.servers.len())
.or_else(|| (state.preferences.servers.len() == 1).then_some(0))
};
if let Some(index) = startup_server {
open_server(&ui, state.clone(), index);
open_home(&ui, state);
}
ui.run()?;
@@ -299,6 +518,86 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
fn wire_callbacks(ui: &AppWindow, state: Arc<Mutex<State>>) {
ui.on_select_tab({
let weak = ui.as_weak();
let state = state.clone();
move |tab| {
let Some(ui) = weak.upgrade() else { return };
let changed = ui.get_tab() != tab;
ui.set_error("".into());
ui.set_tab(tab.clone());
if changed && tab.as_str() == "home" {
open_home(&ui, state.clone());
}
}
});
ui.on_refresh_home({
let weak = ui.as_weak();
let state = state.clone();
move || {
if let Some(ui) = weak.upgrade() {
open_home(&ui, state.clone());
}
}
});
ui.on_refresh_repositories({
let weak = ui.as_weak();
let state = state.clone();
move || {
let server = {
let state = state.lock().unwrap();
state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.cloned()
};
if let (Some(ui), Some(server)) = (weak.upgrade(), server) {
fetch_repositories(&ui, state.clone(), server, true);
}
}
});
ui.on_refresh_issues({
let weak = ui.as_weak();
let state = state.clone();
move || {
let target = {
let state = state.lock().unwrap();
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) {
fetch_issues(&ui, state.clone(), server, owner, repository, true);
}
}
});
ui.on_refresh_issue({
let weak = ui.as_weak();
let state = state.clone();
move || {
let target = {
let state = state.lock().unwrap();
state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.cloned()
.zip(state.active_repository.clone())
.zip(state.active_issue)
};
if let (Some(ui), Some(((server, (owner, repository)), number))) =
(weak.upgrade(), target)
{
fetch_issue(&ui, server, owner, repository, number);
}
}
});
ui.on_show_add_server({
let weak = ui.as_weak();
move || {
@@ -348,12 +647,14 @@ fn wire_callbacks(ui: &AppWindow, state: Arc<Mutex<State>>) {
let index = {
let mut state = state.lock().unwrap();
state.preferences.servers.push(server);
let index = state.preferences.servers.len() - 1;
state.preferences.last_server = Some(index);
if let Err(error) = save_preferences(&state.preferences) {
ui.set_error(error.into());
return;
}
refresh_servers(&ui, &state.preferences);
state.preferences.servers.len() - 1
index
};
open_server(&ui, state, index);
}
@@ -415,13 +716,15 @@ fn wire_callbacks(ui: &AppWindow, state: Arc<Mutex<State>>) {
let weak = ui.as_weak();
let state = state.clone();
move |number| {
let issue = state
.lock()
.unwrap()
.issues
.iter()
.find(|issue| issue.number == Some(number as i64))
.cloned();
let issue = {
let mut state = state.lock().unwrap();
state.active_issue = Some(number as i64);
state
.issues
.iter()
.find(|issue| issue.number == Some(number as i64))
.cloned()
};
if let (Some(ui), Some(issue)) = (weak.upgrade(), issue) {
show_issue(&ui, &issue);
}
@@ -450,21 +753,36 @@ fn open_server(ui: &AppWindow, state: Arc<Mutex<State>>, index: usize) {
return;
};
state.active_server = Some(index);
state.preferences.last_server = Some(index);
state.repositories.clear();
if let Err(error) = save_preferences(&state.preferences) {
ui.set_error(error.into());
}
server
};
ui.set_server_title(server.name.clone().into());
ui.set_repositories(empty_model());
ui.set_page("repositories".into());
ui.set_error("".into());
ui.set_loading(true);
fetch_repositories(ui, state, server, false);
}
fn fetch_repositories(ui: &AppWindow, state: Arc<Mutex<State>>, server: Server, refreshing: bool) {
if refreshing {
ui.set_refreshing(true);
} else {
ui.set_loading(true);
}
let weak = ui.as_weak();
spawn(async move {
let result = load_repositories(&server).await;
post(move || {
let Some(ui) = weak.upgrade() else { return };
ui.set_loading(false);
if refreshing {
ui.set_refreshing(false);
} else {
ui.set_loading(false);
}
match result {
Ok(repositories) => {
let mut state = state.lock().unwrap();
@@ -477,6 +795,50 @@ fn open_server(ui: &AppWindow, state: Arc<Mutex<State>>, index: usize) {
});
}
fn open_home(ui: &AppWindow, state: Arc<Mutex<State>>) {
let server = {
let state = state.lock().unwrap();
state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.cloned()
};
let Some(server) = server else {
ui.set_has_active_server(false);
ui.set_activities(empty_model());
ui.set_heat_cells(empty_model());
ui.set_contribution_count(0);
return;
};
ui.set_has_active_server(true);
ui.set_home_server(server.name.clone().into());
ui.set_home_loading(true);
let server_url = server.url.clone();
let weak = ui.as_weak();
spawn(async move {
let result = load_home(&server).await;
post(move || {
let Some(ui) = weak.upgrade() else { return };
let is_current = {
let state = state.lock().unwrap();
state
.active_server
.and_then(|index| state.preferences.servers.get(index))
.is_some_and(|server| server.url == server_url)
};
if !is_current {
return;
}
ui.set_home_loading(false);
match result {
Ok(home) => refresh_home(&ui, home),
Err(error) => ui.set_error(error.into()),
}
});
});
}
fn open_repository(ui: &AppWindow, state: Arc<Mutex<State>>, owner: String, name: String) {
let server = {
let mut state = state.lock().unwrap();
@@ -492,14 +854,32 @@ fn open_repository(ui: &AppWindow, state: Arc<Mutex<State>>, owner: String, name
ui.set_issues(empty_model());
ui.set_page("issues".into());
ui.set_error("".into());
ui.set_loading(true);
fetch_issues(ui, state, server, owner, name, false);
}
fn fetch_issues(
ui: &AppWindow,
state: Arc<Mutex<State>>,
server: Server,
owner: String,
repository: String,
refreshing: bool,
) {
if refreshing {
ui.set_refreshing(true);
} else {
ui.set_loading(true);
}
let weak = ui.as_weak();
spawn(async move {
let result = load_issues(&server, &owner, &name).await;
let result = load_issues(&server, &owner, &repository).await;
post(move || {
let Some(ui) = weak.upgrade() else { return };
ui.set_loading(false);
if refreshing {
ui.set_refreshing(false);
} else {
ui.set_loading(false);
}
match result {
Ok(issues) => {
let mut state = state.lock().unwrap();
@@ -512,6 +892,29 @@ fn open_repository(ui: &AppWindow, state: Arc<Mutex<State>>, owner: String, name
});
}
fn fetch_issue(ui: &AppWindow, server: Server, owner: String, repository: String, number: i64) {
ui.set_refreshing(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::issue_api::issue_get_issue(&client.configuration(), &owner, &repository, number)
.await
.map_err(|error| error.to_string())
}
.await;
post(move || {
let Some(ui) = weak.upgrade() else { return };
ui.set_refreshing(false);
match result {
Ok(issue) => show_issue(&ui, &issue),
Err(error) => ui.set_error(error.into()),
}
});
});
}
async fn load_repositories(server: &Server) -> Result<Vec<RepositoryData>, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
@@ -580,6 +983,33 @@ async fn load_issues(
.map_err(|error| error.to_string())
}
async fn load_home(server: &Server) -> Result<HomeData, String> {
let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
let configuration = client.configuration();
let login = client
.current_user()
.await
.map_err(|error| error.to_string())?
.login
.ok_or("The server account has no username.")?;
let (activities, heatmap) = tokio::join!(
apis::user_api::user_list_activity_feeds(
&configuration,
&login,
Some(true),
None,
Some(1),
Some(40),
),
apis::user_api::user_get_heatmap_data(&configuration, &login),
);
Ok(HomeData {
activities: activities.map_err(|error| error.to_string())?,
heatmap: heatmap.map_err(|error| error.to_string())?,
})
}
fn refresh_servers(ui: &AppWindow, preferences: &Preferences) {
ui.set_servers(model(
preferences
@@ -633,6 +1063,7 @@ fn refresh_repositories(ui: &AppWindow, state: &State) {
})
.collect(),
));
ui.set_repositories_scroll_request(ui.get_repositories_scroll_request().wrapping_add(1));
}
fn refresh_issues(ui: &AppWindow, issues: &[models::Issue]) {
@@ -653,9 +1084,171 @@ fn refresh_issues(ui: &AppWindow, issues: &[models::Issue]) {
.unwrap_or_else(|| "No description".into())
.into(),
meta: issue_meta(issue).into(),
labels: model(
issue
.labels
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(label_row)
.collect(),
),
})
.collect(),
));
ui.set_issues_scroll_request(ui.get_issues_scroll_request().wrapping_add(1));
}
fn label_row(label: &models::Label) -> Option<LabelRow> {
let name = label.name.as_deref()?.trim();
let hex = label.color.as_deref()?.trim().trim_start_matches('#');
if name.is_empty() || hex.len() != 6 {
return None;
}
let value = u32::from_str_radix(hex, 16).ok()?;
let (red, green, blue) = (
((value >> 16) & 0xff) as u8,
((value >> 8) & 0xff) as u8,
(value & 0xff) as u8,
);
let light = red as u32 * 299 + green as u32 * 587 + blue as u32 * 114 > 150_000;
Some(LabelRow {
name: name.into(),
background: Color::from_rgb_u8(red, green, blue),
foreground: if light {
Color::from_rgb_u8(28, 28, 30)
} else {
Color::from_rgb_u8(255, 255, 255)
},
})
}
fn refresh_home(ui: &AppWindow, home: HomeData) {
let (cells, contributions) = heat_cells(&home.heatmap);
ui.set_heat_cells(model(cells));
ui.set_contribution_count(contributions.min(i32::MAX as i64) as i32);
ui.set_activities(model(home.activities.iter().map(activity_row).collect()));
ui.set_home_scroll_request(ui.get_home_scroll_request().wrapping_add(1));
}
fn activity_row(activity: &models::Activity) -> ActivityRow {
use models::activity::OpType;
let repository = activity
.repo
.as_ref()
.and_then(|repo| repo.full_name.as_deref())
.unwrap_or("repository");
let branch = activity
.ref_name
.as_deref()
.and_then(|name| name.strip_prefix("refs/heads/").or(Some(name)))
.unwrap_or("default branch");
let (icon, title) = match activity.op_type {
Some(OpType::CommitRepo) if activity.content.as_deref().unwrap_or("").is_empty() => {
("", format!("Created branch {branch} in {repository}"))
}
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => {
("", format!("Pushed to {branch} in {repository}"))
}
Some(OpType::CreateRepo) => ("", format!("Created {repository}")),
Some(OpType::RenameRepo) => ("", format!("Renamed {repository}")),
Some(OpType::StarRepo) => ("", format!("Starred {repository}")),
Some(OpType::WatchRepo) => ("", format!("Started watching {repository}")),
Some(OpType::CreateIssue) => ("!", format!("Opened an issue in {repository}")),
Some(OpType::CloseIssue) => ("", format!("Closed an issue in {repository}")),
Some(OpType::ReopenIssue) => ("", format!("Reopened an issue in {repository}")),
Some(OpType::CommentIssue) => ("", format!("Commented on an issue in {repository}")),
Some(OpType::CreatePullRequest) => ("", format!("Opened a pull request in {repository}")),
Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => {
("", format!("Merged a pull request in {repository}"))
}
Some(OpType::ClosePullRequest) => ("×", format!("Closed a pull request in {repository}")),
Some(OpType::ReopenPullRequest) => {
("", format!("Reopened a pull request in {repository}"))
}
Some(OpType::CommentPull) => ("", format!("Commented on a pull request in {repository}")),
Some(OpType::ApprovePullRequest) => {
("", format!("Approved a pull request in {repository}"))
}
Some(OpType::RejectPullRequest) => ("×", format!("Requested changes in {repository}")),
Some(OpType::PushTag) => ("", format!("Pushed tag {branch} in {repository}")),
Some(OpType::DeleteTag) => ("", format!("Deleted tag {branch} in {repository}")),
Some(OpType::DeleteBranch) => ("", format!("Deleted branch {branch} in {repository}")),
Some(OpType::PublishRelease) => ("", format!("Published a release in {repository}")),
Some(_) | None => ("", format!("Updated {repository}")),
};
ActivityRow {
icon: icon.into(),
title: title.into(),
detail: activity_detail(activity).into(),
meta: compact_date(activity.created.as_deref()).into(),
}
}
fn activity_detail(activity: &models::Activity) -> String {
let text = activity
.comment
.as_ref()
.and_then(|comment| comment.body.as_deref())
.or(activity.content.as_deref())
.unwrap_or("");
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(text) {
let commits = payload.get("Len").and_then(|value| value.as_i64());
let message = payload
.get("HeadCommit")
.and_then(|commit| commit.get("Message"))
.and_then(|message| message.as_str())
.map(summary)
.unwrap_or_default();
if !message.is_empty() {
return match commits {
Some(1) => format!("1 commit · {message}"),
Some(count) => format!("{count} commits · {message}"),
None => message,
};
}
}
let text = summary(text);
if text.is_empty() {
"Server activity".into()
} else {
text
}
}
fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
const DAYS: i64 = 52 * 7;
let latest = data
.iter()
.filter_map(|entry| entry.timestamp)
.max()
.unwrap_or(0)
/ 86_400;
let start = latest - DAYS + 1;
let mut counts = vec![0_i64; DAYS as usize];
for entry in data {
let day = entry.timestamp.unwrap_or_default() / 86_400;
if (start..=latest).contains(&day) {
counts[(day - start) as usize] += entry.contributions.unwrap_or_default();
}
}
let maximum = counts.iter().copied().max().unwrap_or_default();
let contributions = counts.iter().sum();
let cells = counts
.into_iter()
.enumerate()
.map(|(index, count)| HeatCell {
week: (index / 7) as i32,
day: (index % 7) as i32,
level: if count == 0 || maximum == 0 {
0
} else {
((count * 4 + maximum - 1) / maximum).clamp(1, 4) as i32
},
})
.collect();
(cells, contributions)
}
fn show_issue(ui: &AppWindow, issue: &models::Issue) {
@@ -675,6 +1268,7 @@ fn show_issue(ui: &AppWindow, issue: &models::Issue) {
.unwrap_or_else(|| "No description provided.".into())
.into(),
);
ui.set_issue_scroll_request(ui.get_issue_scroll_request().wrapping_add(1));
ui.set_page("issue".into());
}
@@ -810,5 +1404,27 @@ mod tests {
favorite_key("https://gitea.example.com", "octo", "demo"),
"https://gitea.example.com|octo/demo"
);
let heatmap = vec![
models::UserHeatmapData {
timestamp: Some(100 * 86_400),
contributions: Some(1),
},
models::UserHeatmapData {
timestamp: Some(101 * 86_400),
contributions: Some(4),
},
];
let (cells, total) = heat_cells(&heatmap);
assert_eq!((cells.len(), total), (364, 5));
assert_eq!((cells[362].level, cells[363].level), (1, 4));
let label = models::Label {
name: Some("bug".into()),
color: Some("d73a4a".into()),
..Default::default()
};
assert!(label_row(&label).is_some());
assert!(label_row(&models::Label::default()).is_none());
}
}