Add activity dashboard and mobile gestures
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user