From 6fcfdb9e5b4319b5657ba5a4aac7d3310a19fb86 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Thu, 30 Jul 2026 14:35:38 +0200 Subject: [PATCH] Add activity dashboard and mobile gestures --- crates/app/src/main.rs | 748 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 682 insertions(+), 66 deletions(-) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index dfdf93b..19a5ebe 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -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 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 icon; + in property label; + in property 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 at_top; + in property 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 tab: "home"; in-out property 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 contribution_count; + in-out property has_active_server; + in-out property home_server; in-out property server_title; in-out property repository_title; in-out property issue_title; in-out property issue_meta; in-out property issue_body; in-out property loading; + in-out property home_loading; + in-out property refreshing; in-out property error; + in-out property home_scroll_request; + in-out property servers_scroll_request; + in-out property repositories_scroll_request; + in-out property issues_scroll_request; + in-out property issue_scroll_request; + private property home_at_top: true; + private property repositories_at_top: true; + private property issues_at_top: true; + private property 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 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 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 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 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 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, #[serde(default)] favorites: BTreeSet, + #[serde(default)] + last_server: Option, } #[derive(Clone)] @@ -266,10 +471,16 @@ struct State { preferences: Preferences, active_server: Option, active_repository: Option<(String, String)>, + active_issue: Option, repositories: Vec, issues: Vec, } +struct HomeData { + activities: Vec, + heatmap: Vec, +} + static RUNTIME: OnceLock = OnceLock::new(); fn main() -> Result<(), Box> { @@ -289,9 +500,17 @@ fn main() -> Result<(), Box> { } 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> { } fn wire_callbacks(ui: &AppWindow, state: Arc>) { + 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>) { 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>) { 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>, 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>, 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>, index: usize) { }); } +fn open_home(ui: &AppWindow, state: Arc>) { + 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>, owner: String, name: String) { let server = { let mut state = state.lock().unwrap(); @@ -492,14 +854,32 @@ fn open_repository(ui: &AppWindow, state: Arc>, 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>, + 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>, 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, 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 { + 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 { + 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::(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, 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()); } }