@@ -6,18 +6,15 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "gotcha-app"
|
||||
path = "src/main.rs"
|
||||
[lib]
|
||||
name = "gotcha_core"
|
||||
crate-type = ["lib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
gotcha_gitea = { path = "../gitea" }
|
||||
pulldown-cmark = "0.13"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
security-framework = "3"
|
||||
slint.workspace = true
|
||||
thiserror = "2"
|
||||
tokio.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
slint-build = "=1.17.1"
|
||||
uniffi = { version = "0.32", features = ["tokio"] }
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
slint_build::compile("ui/app.slint").unwrap();
|
||||
}
|
||||
@@ -7,8 +7,12 @@ use gotcha_gitea::{Client, apis, models};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use crate::{
|
||||
domain::{HistoryCommit, HomeData, RepositoryData, Server},
|
||||
view::compact_date,
|
||||
diff,
|
||||
domain::{
|
||||
HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData,
|
||||
Server,
|
||||
},
|
||||
presentation::compact_date,
|
||||
};
|
||||
|
||||
pub async fn load_repositories(server: &Server) -> Result<Vec<RepositoryData>, String> {
|
||||
@@ -81,6 +85,71 @@ pub async fn load_issues(
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn load_milestones(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
) -> Result<Vec<models::Milestone>, String> {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
let configuration = client.configuration();
|
||||
let mut milestones = Vec::new();
|
||||
for page in 1.. {
|
||||
let batch = apis::issue_api::issue_get_milestones_list(
|
||||
&configuration,
|
||||
owner,
|
||||
repository,
|
||||
Some("all"),
|
||||
None,
|
||||
Some(page),
|
||||
Some(100),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let done = batch.len() < 100;
|
||||
milestones.extend(batch);
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(milestones)
|
||||
}
|
||||
|
||||
pub async fn load_milestone(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
id: i64,
|
||||
) -> Result<MilestoneDetails, String> {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
let configuration = client.configuration();
|
||||
let milestone =
|
||||
apis::issue_api::issue_get_milestone(&configuration, owner, repository, &id.to_string())
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let issues = apis::issue_api::issue_list_issues(
|
||||
&configuration,
|
||||
owner,
|
||||
repository,
|
||||
Some("all"),
|
||||
None,
|
||||
None,
|
||||
Some("issues"),
|
||||
milestone.title.as_deref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(1),
|
||||
Some(100),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(MilestoneDetails { milestone, issues })
|
||||
}
|
||||
|
||||
pub async fn load_pulls(server: &Server, status: &str) -> Result<Vec<models::Issue>, String> {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
@@ -233,6 +302,111 @@ pub async fn load_pull_files(
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub async fn load_issue(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
number: i64,
|
||||
) -> Result<IssueDetails, String> {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
let configuration = client.configuration();
|
||||
let (issue, comments) = tokio::join!(
|
||||
apis::issue_api::issue_get_issue(&configuration, owner, repository, number),
|
||||
apis::issue_api::issue_get_comments(&configuration, owner, repository, number, None, None,),
|
||||
);
|
||||
Ok(IssueDetails {
|
||||
issue: issue.map_err(|error| error.to_string())?,
|
||||
comments: comments.map_err(|error| error.to_string())?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn load_pull(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
number: i64,
|
||||
) -> Result<PullDetails, String> {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
let configuration = client.configuration();
|
||||
let (pull, comments, files) = tokio::join!(
|
||||
apis::repository_api::repo_get_pull_request(&configuration, owner, repository, number,),
|
||||
apis::issue_api::issue_get_comments(&configuration, owner, repository, number, None, None,),
|
||||
load_pull_files(&configuration, owner, repository, number),
|
||||
);
|
||||
Ok(PullDetails {
|
||||
pull: pull.map_err(|error| error.to_string())?,
|
||||
comments: comments.map_err(|error| error.to_string())?,
|
||||
files: files.map_err(|error| error.to_string())?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn load_commit_files(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
sha: &str,
|
||||
) -> Result<Vec<models::CommitAffectedFiles>, String> {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
apis::repository_api::repo_get_single_commit(
|
||||
&client.configuration(),
|
||||
owner,
|
||||
repository,
|
||||
sha,
|
||||
Some(true),
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.await
|
||||
.map(|commit| commit.files.unwrap_or_default())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn load_commit_diff(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
sha: &str,
|
||||
path: &str,
|
||||
) -> Result<diff::Parsed, String> {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
apis::repository_api::repo_download_commit_diff_or_patch(
|
||||
&client.configuration(),
|
||||
owner,
|
||||
repository,
|
||||
sha,
|
||||
"diff",
|
||||
)
|
||||
.await
|
||||
.map(|text| diff::parse_file(&text, path))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub async fn load_pull_diff(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
number: i64,
|
||||
path: &str,
|
||||
) -> Result<diff::Parsed, String> {
|
||||
let client =
|
||||
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||
apis::repository_api::repo_download_pull_diff_or_patch(
|
||||
&client.configuration(),
|
||||
owner,
|
||||
repository,
|
||||
number,
|
||||
"diff",
|
||||
Some(false),
|
||||
)
|
||||
.await
|
||||
.map(|text| diff::parse_file(&text, path))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
async fn load_pull_refs(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
|
||||
@@ -3,35 +3,6 @@ use std::collections::BTreeSet;
|
||||
use gotcha_gitea::models;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum IconStyle {
|
||||
Duotone,
|
||||
Bold,
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
Outline,
|
||||
}
|
||||
|
||||
impl IconStyle {
|
||||
pub fn index(self) -> i32 {
|
||||
match self {
|
||||
Self::Outline => 0,
|
||||
Self::Duotone => 1,
|
||||
Self::Bold => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_index(index: i32) -> Option<Self> {
|
||||
match index {
|
||||
0 => Some(Self::Outline),
|
||||
1 => Some(Self::Duotone),
|
||||
2 => Some(Self::Bold),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppearanceMode {
|
||||
@@ -82,8 +53,6 @@ pub struct Preferences {
|
||||
#[serde(default = "open_status")]
|
||||
pub pull_status: String,
|
||||
#[serde(default)]
|
||||
pub icon_style: IconStyle,
|
||||
#[serde(default)]
|
||||
pub appearance: AppearanceMode,
|
||||
}
|
||||
|
||||
@@ -95,7 +64,6 @@ impl Default for Preferences {
|
||||
last_server: None,
|
||||
issue_status: open_status(),
|
||||
pull_status: open_status(),
|
||||
icon_style: IconStyle::default(),
|
||||
appearance: AppearanceMode::default(),
|
||||
}
|
||||
}
|
||||
@@ -126,17 +94,7 @@ pub struct HistoryCommit {
|
||||
pub struct State {
|
||||
pub preferences: Preferences,
|
||||
pub active_server: Option<usize>,
|
||||
pub active_repository: Option<(String, String)>,
|
||||
pub active_issue: Option<i64>,
|
||||
pub active_pull: Option<(String, String, i64)>,
|
||||
pub active_commit: Option<String>,
|
||||
pub opened_from_home: bool,
|
||||
pub repositories: Vec<RepositoryData>,
|
||||
pub issues: Vec<models::Issue>,
|
||||
pub pulls: Vec<models::Issue>,
|
||||
pub branches: Vec<String>,
|
||||
pub active_branch: Option<String>,
|
||||
pub commits: Vec<HistoryCommit>,
|
||||
}
|
||||
|
||||
pub struct HomeData {
|
||||
@@ -149,6 +107,11 @@ pub struct IssueDetails {
|
||||
pub comments: Vec<models::Comment>,
|
||||
}
|
||||
|
||||
pub struct MilestoneDetails {
|
||||
pub milestone: models::Milestone,
|
||||
pub issues: Vec<models::Issue>,
|
||||
}
|
||||
|
||||
pub struct PullDetails {
|
||||
pub pull: models::PullRequest,
|
||||
pub comments: Vec<models::Comment>,
|
||||
|
||||
355
crates/app/src/lib.rs
Normal file
355
crates/app/src/lib.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use gotcha_gitea::Client;
|
||||
use thiserror::Error;
|
||||
|
||||
mod activity;
|
||||
mod api;
|
||||
mod diff;
|
||||
mod domain;
|
||||
mod presentation;
|
||||
mod storage;
|
||||
|
||||
use api::*;
|
||||
use domain::*;
|
||||
pub use presentation::*;
|
||||
use storage::*;
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
|
||||
#[derive(Debug, Error, uniffi::Error)]
|
||||
pub enum GotchaError {
|
||||
#[error("{message}")]
|
||||
Message { message: String },
|
||||
}
|
||||
|
||||
impl From<String> for GotchaError {
|
||||
fn from(message: String) -> Self {
|
||||
Self::Message { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for GotchaError {
|
||||
fn from(message: &str) -> Self {
|
||||
message.to_string().into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct Settings {
|
||||
pub issue_status: String,
|
||||
pub pull_status: String,
|
||||
pub appearance: u32,
|
||||
}
|
||||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct GotchaCore {
|
||||
state: Mutex<State>,
|
||||
startup_error: Option<String>,
|
||||
}
|
||||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
impl GotchaCore {
|
||||
#[uniffi::constructor]
|
||||
pub fn new() -> Arc<Self> {
|
||||
let (preferences, startup_error) = match load_preferences() {
|
||||
Ok(preferences) => (preferences, None),
|
||||
Err(error) => (Preferences::default(), Some(error)),
|
||||
};
|
||||
let active_server = preferences
|
||||
.last_server
|
||||
.filter(|index| *index < preferences.servers.len())
|
||||
.or_else(|| (preferences.servers.len() == 1).then_some(0));
|
||||
Arc::new(Self {
|
||||
state: Mutex::new(State {
|
||||
preferences,
|
||||
active_server,
|
||||
..Default::default()
|
||||
}),
|
||||
startup_error,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn startup_error(&self) -> Option<String> {
|
||||
self.startup_error.clone()
|
||||
}
|
||||
|
||||
pub fn servers(&self) -> Vec<ServerRow> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.preferences
|
||||
.servers
|
||||
.iter()
|
||||
.map(|server| ServerRow {
|
||||
name: server.name.clone(),
|
||||
url: server.url.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn active_server_index(&self) -> Option<u32> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.active_server
|
||||
.map(|index| index as u32)
|
||||
}
|
||||
|
||||
pub fn active_server_name(&self) -> Option<String> {
|
||||
let state = self.state.lock().unwrap();
|
||||
state
|
||||
.active_server
|
||||
.and_then(|index| state.preferences.servers.get(index))
|
||||
.map(|server| server.name.clone())
|
||||
}
|
||||
|
||||
pub fn select_server(&self, index: u32) -> Result<(), GotchaError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let index = index as usize;
|
||||
if index >= state.preferences.servers.len() {
|
||||
return Err("That server no longer exists.".into());
|
||||
}
|
||||
state.active_server = Some(index);
|
||||
state.preferences.last_server = Some(index);
|
||||
state.repositories.clear();
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn add_server(
|
||||
&self,
|
||||
name: String,
|
||||
url: String,
|
||||
token: String,
|
||||
) -> Result<u32, GotchaError> {
|
||||
let server = validate_server(&name, &url, &token)?;
|
||||
Client::new(&server.url, Some(&server.token))
|
||||
.map_err(|error| error.to_string())?
|
||||
.current_user()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
save_server_token(&server)?;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.preferences.servers.push(server);
|
||||
let index = state.preferences.servers.len() - 1;
|
||||
state.preferences.last_server = Some(index);
|
||||
state.active_server = Some(index);
|
||||
save_preferences(&state.preferences)?;
|
||||
Ok(index as u32)
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> Settings {
|
||||
let state = self.state.lock().unwrap();
|
||||
Settings {
|
||||
issue_status: state.preferences.issue_status.clone(),
|
||||
pull_status: state.preferences.pull_status.clone(),
|
||||
appearance: state.preferences.appearance.index() as u32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_issue_status(&self, status: String) -> Result<(), GotchaError> {
|
||||
if !matches!(status.as_str(), "open" | "closed") {
|
||||
return Err("Unsupported issue status.".into());
|
||||
}
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.preferences.issue_status = status;
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_pull_status(&self, status: String) -> Result<(), GotchaError> {
|
||||
if !matches!(status.as_str(), "open" | "closed") {
|
||||
return Err("Unsupported pull-request status.".into());
|
||||
}
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.preferences.pull_status = status;
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_appearance(&self, index: u32) -> Result<(), GotchaError> {
|
||||
let appearance =
|
||||
AppearanceMode::from_index(index as i32).ok_or("Unsupported appearance.")?;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.preferences.appearance = appearance;
|
||||
save_preferences(&state.preferences).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn home(&self) -> Result<HomePage, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let name = server.name.clone();
|
||||
Ok(home_page(name, load_home(&server).await?))
|
||||
}
|
||||
|
||||
pub async fn repositories(&self) -> Result<Vec<RepositoryRow>, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let repositories = load_repositories(&server).await?;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.repositories = repositories;
|
||||
Ok(self.repository_rows(&state))
|
||||
}
|
||||
|
||||
pub fn toggle_favorite(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
) -> Result<Vec<RepositoryRow>, GotchaError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let server = state
|
||||
.active_server
|
||||
.and_then(|index| state.preferences.servers.get(index))
|
||||
.ok_or("Select a server first.")?;
|
||||
let key = favorite_key(&server.url, &owner, &repository);
|
||||
if !state.preferences.favorites.remove(&key) {
|
||||
state.preferences.favorites.insert(key);
|
||||
}
|
||||
save_preferences(&state.preferences)?;
|
||||
Ok(self.repository_rows(&state))
|
||||
}
|
||||
|
||||
pub async fn issues(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
) -> Result<Vec<IssueRow>, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let status = self.state.lock().unwrap().preferences.issue_status.clone();
|
||||
Ok(issue_rows(
|
||||
&load_issues(&server, &owner, &repository, &status).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn issue(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
) -> Result<IssuePage, GotchaError> {
|
||||
Ok(issue_page(
|
||||
load_issue(&self.server()?, &owner, &repository, number).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn milestones(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
) -> Result<Vec<MilestoneRow>, GotchaError> {
|
||||
Ok(milestone_rows(
|
||||
&load_milestones(&self.server()?, &owner, &repository).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn milestone(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
id: i64,
|
||||
) -> Result<MilestonePage, GotchaError> {
|
||||
Ok(milestone_page(
|
||||
load_milestone(&self.server()?, &owner, &repository, id).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn pulls(&self) -> Result<Vec<PullRow>, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let status = self.state.lock().unwrap().preferences.pull_status.clone();
|
||||
Ok(pull_rows(&load_pulls(&server, &status).await?))
|
||||
}
|
||||
|
||||
pub async fn pull(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
) -> Result<PullPage, GotchaError> {
|
||||
Ok(pull_page(
|
||||
load_pull(&self.server()?, &owner, &repository, number).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn commits(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
branch: Option<String>,
|
||||
) -> Result<CommitPage, GotchaError> {
|
||||
let server = self.server()?;
|
||||
let default_branch = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.repositories
|
||||
.iter()
|
||||
.find(|candidate| candidate.owner == owner && candidate.name == repository)
|
||||
.map(|candidate| candidate.default_branch.clone())
|
||||
.unwrap_or_else(|| "main".into());
|
||||
let branches = load_branches(&server, &owner, &repository, &default_branch).await?;
|
||||
let commits = match branch.as_deref() {
|
||||
Some(branch) => load_branch_commits(&server, &owner, &repository, branch).await?,
|
||||
None => load_all_commits(&server, &owner, &repository, &branches).await?,
|
||||
};
|
||||
Ok(commit_page(branches, &commits, branch.is_none()))
|
||||
}
|
||||
|
||||
pub async fn commit_files(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
sha: String,
|
||||
) -> Result<Vec<FileRow>, GotchaError> {
|
||||
Ok(commit_file_rows(
|
||||
load_commit_files(&self.server()?, &owner, &repository, &sha).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn commit_diff(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
sha: String,
|
||||
path: String,
|
||||
) -> Result<DiffPage, GotchaError> {
|
||||
Ok(diff_page(
|
||||
&path,
|
||||
load_commit_diff(&self.server()?, &owner, &repository, &sha, &path).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn pull_diff(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
path: String,
|
||||
) -> Result<DiffPage, GotchaError> {
|
||||
Ok(diff_page(
|
||||
&path,
|
||||
load_pull_diff(&self.server()?, &owner, &repository, number, &path).await?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl GotchaCore {
|
||||
fn server(&self) -> Result<Server, GotchaError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
state
|
||||
.active_server
|
||||
.and_then(|index| state.preferences.servers.get(index))
|
||||
.cloned()
|
||||
.ok_or_else(|| "Select a server first.".to_string().into())
|
||||
}
|
||||
|
||||
fn repository_rows(&self, state: &State) -> Vec<RepositoryRow> {
|
||||
let server_url = state
|
||||
.active_server
|
||||
.and_then(|index| state.preferences.servers.get(index))
|
||||
.map(|server| server.url.as_str())
|
||||
.unwrap_or_default();
|
||||
repository_rows(&state.repositories, |repository| {
|
||||
state.preferences.favorites.contains(&favorite_key(
|
||||
server_url,
|
||||
&repository.owner,
|
||||
&repository.name,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,312 +0,0 @@
|
||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use slint::StyledText;
|
||||
|
||||
use crate::ui::MarkdownBlock;
|
||||
|
||||
struct Draft {
|
||||
kind: &'static str,
|
||||
source: String,
|
||||
plain: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Renderer {
|
||||
blocks: Vec<MarkdownBlock>,
|
||||
current: Option<Draft>,
|
||||
lists: Vec<Option<u64>>,
|
||||
links: Vec<(String, bool, bool)>,
|
||||
quote_depth: usize,
|
||||
}
|
||||
|
||||
pub fn render(source: &str) -> Vec<MarkdownBlock> {
|
||||
let mut renderer = Renderer::default();
|
||||
for event in Parser::new_ext(source, Options::all()) {
|
||||
renderer.event(event);
|
||||
}
|
||||
renderer.finish();
|
||||
renderer.blocks
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
fn event(&mut self, event: Event<'_>) {
|
||||
match event {
|
||||
Event::Start(tag) => self.start(tag),
|
||||
Event::End(tag) => self.end(tag),
|
||||
Event::Text(text) => self.text(&text),
|
||||
Event::Code(text) | Event::InlineMath(text) | Event::DisplayMath(text) => {
|
||||
if self.is_plain() {
|
||||
self.raw(&text);
|
||||
} else {
|
||||
self.raw(&code_span(&text));
|
||||
}
|
||||
}
|
||||
Event::Html(html) | Event::InlineHtml(html) => self.text(&html),
|
||||
Event::FootnoteReference(label) => self.text(&format!("[{label}]")),
|
||||
Event::SoftBreak | Event::HardBreak => self.raw("\n"),
|
||||
Event::Rule => {
|
||||
self.begin("rule", true);
|
||||
self.finish();
|
||||
}
|
||||
Event::TaskListMarker(checked) => self.raw(if checked { "☑ " } else { "☐ " }),
|
||||
}
|
||||
}
|
||||
|
||||
fn start(&mut self, tag: Tag<'_>) {
|
||||
match tag {
|
||||
Tag::Paragraph => {
|
||||
if self.current.is_none() {
|
||||
self.begin(
|
||||
if self.quote_depth > 0 {
|
||||
"quote"
|
||||
} else {
|
||||
"paragraph"
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else if self
|
||||
.current
|
||||
.as_ref()
|
||||
.is_some_and(|draft| !draft.source.is_empty() && !draft.source.ends_with(' '))
|
||||
{
|
||||
self.raw("\n");
|
||||
}
|
||||
}
|
||||
Tag::Heading { level, .. } => {
|
||||
self.begin(heading_kind(level), false);
|
||||
self.raw("**");
|
||||
}
|
||||
Tag::BlockQuote(_) => {
|
||||
self.quote_depth += 1;
|
||||
if self.current.is_none() {
|
||||
self.begin("quote", false);
|
||||
} else {
|
||||
self.raw("› ");
|
||||
}
|
||||
}
|
||||
Tag::CodeBlock(_) => self.begin("code", true),
|
||||
Tag::HtmlBlock | Tag::MetadataBlock(_) => self.begin("code", true),
|
||||
Tag::List(start) => self.lists.push(start),
|
||||
Tag::Item => {
|
||||
self.begin("list", false);
|
||||
let indent = "\u{00a0}\u{00a0}".repeat(self.lists.len().saturating_sub(1));
|
||||
self.raw(&indent);
|
||||
let prefix = match self.lists.last_mut() {
|
||||
Some(Some(number)) => {
|
||||
let prefix = format!("{number}\\. ");
|
||||
*number += 1;
|
||||
prefix
|
||||
}
|
||||
_ => "• ".into(),
|
||||
};
|
||||
self.raw(&prefix);
|
||||
}
|
||||
Tag::FootnoteDefinition(label) => {
|
||||
self.begin("footnote", false);
|
||||
self.text(&format!("[{label}] "));
|
||||
}
|
||||
Tag::DefinitionList => {}
|
||||
Tag::DefinitionListTitle => {
|
||||
self.begin("paragraph", false);
|
||||
self.raw("**");
|
||||
}
|
||||
Tag::DefinitionListDefinition => {
|
||||
self.begin("paragraph", false);
|
||||
self.raw("↳ ");
|
||||
}
|
||||
Tag::Table(_) => self.begin("table", true),
|
||||
Tag::TableHead | Tag::TableRow | Tag::TableCell => {}
|
||||
Tag::Emphasis => self.marker("*"),
|
||||
Tag::Strong => self.marker("**"),
|
||||
Tag::Strikethrough => self.marker("~~"),
|
||||
Tag::Superscript => self.raw("\\^"),
|
||||
Tag::Subscript => self.raw("\\~"),
|
||||
Tag::Link { dest_url, .. } => self.start_link(dest_url.into_string(), false),
|
||||
Tag::Image { dest_url, .. } => self.start_link(dest_url.into_string(), true),
|
||||
}
|
||||
}
|
||||
|
||||
fn end(&mut self, tag: TagEnd) {
|
||||
match tag {
|
||||
TagEnd::Paragraph => {
|
||||
if self.quote_depth == 0
|
||||
&& !self
|
||||
.current
|
||||
.as_ref()
|
||||
.is_some_and(|draft| draft.kind == "list")
|
||||
{
|
||||
self.finish();
|
||||
}
|
||||
}
|
||||
TagEnd::Heading(_) => {
|
||||
self.raw("**");
|
||||
self.finish();
|
||||
}
|
||||
TagEnd::BlockQuote(_) => {
|
||||
self.quote_depth = self.quote_depth.saturating_sub(1);
|
||||
if self.quote_depth == 0 {
|
||||
self.finish();
|
||||
}
|
||||
}
|
||||
TagEnd::CodeBlock | TagEnd::HtmlBlock | TagEnd::MetadataBlock(_) => self.finish(),
|
||||
TagEnd::List(_) => {
|
||||
self.lists.pop();
|
||||
}
|
||||
TagEnd::Item | TagEnd::FootnoteDefinition => self.finish(),
|
||||
TagEnd::DefinitionList => {}
|
||||
TagEnd::DefinitionListTitle => {
|
||||
self.raw("**");
|
||||
self.finish();
|
||||
}
|
||||
TagEnd::DefinitionListDefinition => self.finish(),
|
||||
TagEnd::Table => self.finish(),
|
||||
TagEnd::TableHead => self.raw("────────\n"),
|
||||
TagEnd::TableRow => {
|
||||
if let Some(draft) = self.current.as_mut()
|
||||
&& draft.source.ends_with(" │ ")
|
||||
{
|
||||
draft.source.truncate(draft.source.len() - " │ ".len());
|
||||
}
|
||||
self.raw("\n");
|
||||
}
|
||||
TagEnd::TableCell => self.raw(" │ "),
|
||||
TagEnd::Emphasis => self.marker("*"),
|
||||
TagEnd::Strong => self.marker("**"),
|
||||
TagEnd::Strikethrough => self.marker("~~"),
|
||||
TagEnd::Superscript => self.raw("\\^"),
|
||||
TagEnd::Subscript => self.raw("\\~"),
|
||||
TagEnd::Link | TagEnd::Image => self.end_link(),
|
||||
}
|
||||
}
|
||||
|
||||
fn begin(&mut self, kind: &'static str, plain: bool) {
|
||||
self.finish();
|
||||
self.current = Some(Draft {
|
||||
kind,
|
||||
source: String::new(),
|
||||
plain,
|
||||
});
|
||||
}
|
||||
|
||||
fn finish(&mut self) {
|
||||
let Some(draft) = self.current.take() else {
|
||||
return;
|
||||
};
|
||||
let source = draft.source.trim_matches('\n');
|
||||
if source.is_empty() && draft.kind != "rule" {
|
||||
return;
|
||||
}
|
||||
let text = if draft.plain {
|
||||
StyledText::from_plain_text(source)
|
||||
} else {
|
||||
StyledText::from_markdown(source)
|
||||
.unwrap_or_else(|_| StyledText::from_plain_text(source))
|
||||
};
|
||||
self.blocks.push(MarkdownBlock {
|
||||
kind: draft.kind.into(),
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
fn text(&mut self, text: &str) {
|
||||
if self.current.is_none() {
|
||||
self.begin(
|
||||
if self.quote_depth > 0 {
|
||||
"quote"
|
||||
} else {
|
||||
"paragraph"
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
if self.is_plain() {
|
||||
self.raw(text);
|
||||
return;
|
||||
}
|
||||
let mut escaped = String::with_capacity(text.len());
|
||||
for character in text.chars() {
|
||||
if character.is_ascii_punctuation() {
|
||||
escaped.push('\\');
|
||||
}
|
||||
escaped.push(character);
|
||||
}
|
||||
self.raw(&escaped);
|
||||
}
|
||||
|
||||
fn raw(&mut self, text: &str) {
|
||||
if self.current.is_none() {
|
||||
self.begin("paragraph", false);
|
||||
}
|
||||
self.current.as_mut().unwrap().source.push_str(text);
|
||||
}
|
||||
|
||||
fn marker(&mut self, marker: &str) {
|
||||
if !self.is_plain() {
|
||||
self.raw(marker);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_plain(&self) -> bool {
|
||||
self.current.as_ref().is_some_and(|draft| draft.plain)
|
||||
}
|
||||
|
||||
fn start_link(&mut self, url: String, image: bool) {
|
||||
let styled = !self.is_plain();
|
||||
if styled {
|
||||
self.raw(if image { "[🖼 " } else { "[" });
|
||||
} else if image {
|
||||
self.raw("🖼 ");
|
||||
}
|
||||
self.links.push((url, image, styled));
|
||||
}
|
||||
|
||||
fn end_link(&mut self) {
|
||||
let Some((url, _, styled)) = self.links.pop() else {
|
||||
return;
|
||||
};
|
||||
if styled {
|
||||
self.raw(&format!("](<{}>)", url.replace('>', "%3E")));
|
||||
} else {
|
||||
self.raw(&format!(" ({url})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn heading_kind(level: HeadingLevel) -> &'static str {
|
||||
match level {
|
||||
HeadingLevel::H1 => "heading1",
|
||||
HeadingLevel::H2 => "heading2",
|
||||
HeadingLevel::H3 => "heading3",
|
||||
HeadingLevel::H4 => "heading4",
|
||||
HeadingLevel::H5 => "heading5",
|
||||
HeadingLevel::H6 => "heading6",
|
||||
}
|
||||
}
|
||||
|
||||
fn code_span(text: &str) -> String {
|
||||
let longest = text
|
||||
.split(|character| character != '`')
|
||||
.map(str::len)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let fence = "`".repeat(longest + 1);
|
||||
format!("{fence} {text} {fence}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn renders_extended_markdown_as_safe_styled_blocks() {
|
||||
let blocks = render(
|
||||
"# Title\n\n- [x] **done** with [link](https://example.com)\n\n> quoted\n\n```rust\nlet x = 1;\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |",
|
||||
);
|
||||
let kinds: Vec<_> = blocks.iter().map(|block| block.kind.as_str()).collect();
|
||||
assert_eq!(kinds, ["heading1", "list", "quote", "code", "table"]);
|
||||
assert_eq!(
|
||||
blocks[0].text,
|
||||
StyledText::from_markdown("**Title**").unwrap()
|
||||
);
|
||||
assert_eq!(blocks[3].text, StyledText::from_plain_text("let x = 1;"));
|
||||
}
|
||||
}
|
||||
800
crates/app/src/presentation.rs
Normal file
800
crates/app/src/presentation.rs
Normal file
@@ -0,0 +1,800 @@
|
||||
use gotcha_gitea::models;
|
||||
|
||||
use crate::{
|
||||
activity,
|
||||
domain::{
|
||||
HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct ServerRow {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct RepositoryRow {
|
||||
pub name: String,
|
||||
pub owner: String,
|
||||
pub description: String,
|
||||
pub meta: String,
|
||||
pub favorite: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct LabelRow {
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub light: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct IssueRow {
|
||||
pub number: i64,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub meta: String,
|
||||
pub milestone: String,
|
||||
pub labels: Vec<LabelRow>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct PullRow {
|
||||
pub number: i64,
|
||||
pub owner: String,
|
||||
pub repository: String,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub meta: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct CommentRow {
|
||||
pub author: String,
|
||||
pub body: String,
|
||||
pub meta: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct IssuePage {
|
||||
pub title: String,
|
||||
pub meta: String,
|
||||
pub milestone: String,
|
||||
pub body: String,
|
||||
pub comments: Vec<CommentRow>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MilestoneRow {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub meta: String,
|
||||
pub open_issues: i64,
|
||||
pub closed_issues: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MilestonePage {
|
||||
pub milestone: MilestoneRow,
|
||||
pub issues: Vec<IssueRow>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct PullPage {
|
||||
pub title: String,
|
||||
pub meta: String,
|
||||
pub body: String,
|
||||
pub files_ref: String,
|
||||
pub files: Vec<FileRow>,
|
||||
pub comments: Vec<CommentRow>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct CommitRow {
|
||||
pub sha: String,
|
||||
pub title: String,
|
||||
pub meta: String,
|
||||
pub refs: String,
|
||||
pub top_lanes: Vec<u32>,
|
||||
pub bottom_lanes: Vec<u32>,
|
||||
pub node_lane: Option<u32>,
|
||||
pub connections: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct CommitPage {
|
||||
pub branches: Vec<String>,
|
||||
pub commits: Vec<CommitRow>,
|
||||
pub lane_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct FileRow {
|
||||
pub path: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct DiffLine {
|
||||
pub old_number: String,
|
||||
pub new_number: String,
|
||||
pub text: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct DiffPage {
|
||||
pub title: String,
|
||||
pub columns: u32,
|
||||
pub lines: Vec<DiffLine>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct ActivityRow {
|
||||
pub icon: String,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
pub meta: String,
|
||||
pub target: String,
|
||||
pub owner: String,
|
||||
pub repository: String,
|
||||
pub number: i64,
|
||||
pub sha: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct HeatCell {
|
||||
pub week: u32,
|
||||
pub day: u32,
|
||||
pub level: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct HomePage {
|
||||
pub server_name: String,
|
||||
pub activities: Vec<ActivityRow>,
|
||||
pub heat_cells: Vec<HeatCell>,
|
||||
pub contribution_count: i64,
|
||||
}
|
||||
|
||||
pub fn repository_rows(
|
||||
repositories: &[RepositoryData],
|
||||
is_favorite: impl Fn(&RepositoryData) -> bool,
|
||||
) -> Vec<RepositoryRow> {
|
||||
let mut repositories = repositories.to_vec();
|
||||
repositories
|
||||
.sort_by_key(|repository| (!is_favorite(repository), repository.name.to_lowercase()));
|
||||
repositories
|
||||
.into_iter()
|
||||
.map(|repository| RepositoryRow {
|
||||
favorite: is_favorite(&repository),
|
||||
meta: format!(
|
||||
"{} · {} open · {}",
|
||||
repository.language, repository.open_issues, repository.updated
|
||||
),
|
||||
name: repository.name,
|
||||
owner: repository.owner,
|
||||
description: repository.description,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn issue_rows(issues: &[models::Issue]) -> Vec<IssueRow> {
|
||||
issues
|
||||
.iter()
|
||||
.map(|issue| IssueRow {
|
||||
number: issue.number.unwrap_or_default(),
|
||||
title: issue
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Untitled issue".into()),
|
||||
summary: issue
|
||||
.body
|
||||
.as_deref()
|
||||
.map(summary)
|
||||
.unwrap_or_else(|| "No description".into()),
|
||||
meta: issue_meta(issue),
|
||||
milestone: issue_milestone(issue),
|
||||
labels: issue
|
||||
.labels
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(label_row)
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn milestone_rows(milestones: &[models::Milestone]) -> Vec<MilestoneRow> {
|
||||
let mut milestones = milestones.to_vec();
|
||||
milestones.sort_by_key(|milestone| {
|
||||
(
|
||||
milestone.state.as_deref() == Some("closed"),
|
||||
milestone
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase(),
|
||||
)
|
||||
});
|
||||
milestones.iter().map(milestone_row).collect()
|
||||
}
|
||||
|
||||
pub fn milestone_page(details: MilestoneDetails) -> MilestonePage {
|
||||
MilestonePage {
|
||||
milestone: milestone_row(&details.milestone),
|
||||
issues: issue_rows(&details.issues),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pull_rows(pulls: &[models::Issue]) -> Vec<PullRow> {
|
||||
pulls
|
||||
.iter()
|
||||
.filter_map(|pull| {
|
||||
let repository = pull.repository.as_ref()?;
|
||||
let author = pull
|
||||
.user
|
||||
.as_ref()
|
||||
.and_then(|user| user.login.as_deref())
|
||||
.unwrap_or("unknown");
|
||||
let kind = if pull
|
||||
.pull_request
|
||||
.as_ref()
|
||||
.and_then(|pull| pull.draft)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"Draft"
|
||||
} else {
|
||||
pull.state.as_deref().unwrap_or("unknown")
|
||||
};
|
||||
Some(PullRow {
|
||||
number: pull.number.unwrap_or_default(),
|
||||
owner: repository.owner.clone()?,
|
||||
repository: repository.name.clone()?,
|
||||
title: pull
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Untitled pull request".into()),
|
||||
summary: pull
|
||||
.body
|
||||
.as_deref()
|
||||
.map(summary)
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or_else(|| "No description".into()),
|
||||
meta: format!(
|
||||
"{kind} · {author} · {} · {} comments",
|
||||
compact_date(pull.updated_at.as_deref()),
|
||||
pull.comments.unwrap_or_default()
|
||||
),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn commit_page(
|
||||
branches: Vec<String>,
|
||||
commits: &[HistoryCommit],
|
||||
show_graph: bool,
|
||||
) -> CommitPage {
|
||||
let lane_count = if show_graph {
|
||||
commits
|
||||
.iter()
|
||||
.flat_map(|commit| {
|
||||
commit
|
||||
.top_lanes
|
||||
.iter()
|
||||
.chain(commit.bottom_lanes.iter())
|
||||
.chain(commit.connections.iter())
|
||||
.chain(commit.node_lane.iter())
|
||||
})
|
||||
.max()
|
||||
.map_or(0, |lane| lane + 1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let commits = commits
|
||||
.iter()
|
||||
.filter_map(|row| {
|
||||
let commit = &row.commit;
|
||||
let sha = commit.sha.as_deref()?;
|
||||
let details = commit.commit.as_ref();
|
||||
let author = details
|
||||
.and_then(|commit| commit.author.as_ref())
|
||||
.and_then(|author| author.name.as_deref())
|
||||
.or_else(|| {
|
||||
commit
|
||||
.author
|
||||
.as_ref()
|
||||
.and_then(|author| author.login.as_deref())
|
||||
})
|
||||
.unwrap_or("unknown");
|
||||
let date = details
|
||||
.and_then(|commit| commit.author.as_ref())
|
||||
.and_then(|author| author.date.as_deref())
|
||||
.or(commit.created.as_deref());
|
||||
Some(CommitRow {
|
||||
sha: sha.into(),
|
||||
title: details
|
||||
.and_then(|commit| commit.message.as_deref())
|
||||
.map(|message| message.lines().next().unwrap_or(message))
|
||||
.unwrap_or("Commit")
|
||||
.into(),
|
||||
meta: format!("{author} · {}", compact_date(date)),
|
||||
refs: row.refs.join(" · "),
|
||||
top_lanes: indices(&row.top_lanes),
|
||||
bottom_lanes: indices(&row.bottom_lanes),
|
||||
node_lane: row.node_lane.map(|lane| lane as u32),
|
||||
connections: indices(&row.connections),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
CommitPage {
|
||||
branches,
|
||||
commits,
|
||||
lane_count: lane_count as u32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn issue_page(details: IssueDetails) -> IssuePage {
|
||||
IssuePage {
|
||||
title: details
|
||||
.issue
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Untitled issue".into()),
|
||||
meta: issue_meta(&details.issue),
|
||||
milestone: issue_milestone(&details.issue),
|
||||
body: details
|
||||
.issue
|
||||
.body
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or_else(|| "No description provided.".into()),
|
||||
comments: details.comments.iter().map(comment_row).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pull_page(details: PullDetails) -> PullPage {
|
||||
let pull = &details.pull;
|
||||
let author = pull
|
||||
.user
|
||||
.as_ref()
|
||||
.and_then(|user| user.login.as_deref())
|
||||
.unwrap_or("unknown");
|
||||
let head = pull
|
||||
.head
|
||||
.as_ref()
|
||||
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
|
||||
.unwrap_or("head");
|
||||
let base = pull
|
||||
.base
|
||||
.as_ref()
|
||||
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
|
||||
.unwrap_or("base");
|
||||
let state = if pull.merged.unwrap_or(false) {
|
||||
"merged"
|
||||
} else if pull.draft.unwrap_or(false) {
|
||||
"draft"
|
||||
} else {
|
||||
pull.state.as_deref().unwrap_or("unknown")
|
||||
};
|
||||
PullPage {
|
||||
title: pull
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Untitled pull request".into()),
|
||||
meta: format!(
|
||||
"#{} · {state} · {author} · {head} → {base}\n{} files · +{} −{} · {} comments",
|
||||
pull.number.unwrap_or_default(),
|
||||
pull.changed_files.unwrap_or_default(),
|
||||
pull.additions.unwrap_or_default(),
|
||||
pull.deletions.unwrap_or_default(),
|
||||
pull.comments.unwrap_or_default(),
|
||||
),
|
||||
body: pull
|
||||
.body
|
||||
.clone()
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or_else(|| "No description provided.".into()),
|
||||
files_ref: pull_files_ref(pull),
|
||||
files: details.files.iter().filter_map(file_row).collect(),
|
||||
comments: details.comments.iter().map(comment_row).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_rows(files: Vec<models::ChangedFile>) -> Vec<FileRow> {
|
||||
files.iter().filter_map(file_row).collect()
|
||||
}
|
||||
|
||||
pub fn commit_file_rows(files: Vec<models::CommitAffectedFiles>) -> Vec<FileRow> {
|
||||
files
|
||||
.into_iter()
|
||||
.filter_map(|file| {
|
||||
Some(FileRow {
|
||||
path: file.filename?,
|
||||
status: file.status.unwrap_or_else(|| "modified".into()),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn diff_page(path: &str, diff: crate::diff::Parsed) -> DiffPage {
|
||||
DiffPage {
|
||||
title: path.rsplit('/').next().unwrap_or(path).into(),
|
||||
columns: diff.columns.min(u32::MAX as usize) as u32,
|
||||
lines: diff
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|line| DiffLine {
|
||||
old_number: line.old_number,
|
||||
new_number: line.new_number,
|
||||
text: line.text,
|
||||
kind: line.kind.into(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn home_page(server_name: String, home: HomeData) -> HomePage {
|
||||
let (heat_cells, contribution_count) = heat_cells(&home.heatmap);
|
||||
HomePage {
|
||||
server_name,
|
||||
activities: home.activities.iter().map(activity_row).collect(),
|
||||
heat_cells,
|
||||
contribution_count,
|
||||
}
|
||||
}
|
||||
|
||||
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() => (
|
||||
"branch-create",
|
||||
format!("Created branch {branch} in {repository}"),
|
||||
),
|
||||
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => {
|
||||
("push", format!("Pushed to {branch} in {repository}"))
|
||||
}
|
||||
Some(OpType::CreateRepo) => ("repo-create", format!("Created {repository}")),
|
||||
Some(OpType::RenameRepo) => ("repo-rename", format!("Renamed {repository}")),
|
||||
Some(OpType::StarRepo) => ("repo-star", format!("Starred {repository}")),
|
||||
Some(OpType::WatchRepo) => ("repo-watch", format!("Started watching {repository}")),
|
||||
Some(OpType::CreateIssue) => ("issue-open", format!("Opened an issue in {repository}")),
|
||||
Some(OpType::CloseIssue) => ("issue-close", format!("Closed an issue in {repository}")),
|
||||
Some(OpType::ReopenIssue) => ("issue-reopen", format!("Reopened an issue in {repository}")),
|
||||
Some(OpType::CommentIssue) => (
|
||||
"issue-comment",
|
||||
format!("Commented on an issue in {repository}"),
|
||||
),
|
||||
Some(OpType::CreatePullRequest) => (
|
||||
"pull-open",
|
||||
format!("Opened a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => (
|
||||
"pull-merge",
|
||||
format!("Merged a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::ClosePullRequest) => (
|
||||
"pull-close",
|
||||
format!("Closed a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::ReopenPullRequest) => (
|
||||
"pull-reopen",
|
||||
format!("Reopened a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::CommentPull) => (
|
||||
"pull-comment",
|
||||
format!("Commented on a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::ApprovePullRequest) => (
|
||||
"pull-approve",
|
||||
format!("Approved a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::RejectPullRequest) => {
|
||||
("pull-reject", format!("Requested changes in {repository}"))
|
||||
}
|
||||
Some(OpType::PushTag) => ("tag-push", format!("Pushed tag {branch} in {repository}")),
|
||||
Some(OpType::DeleteTag) => (
|
||||
"tag-delete",
|
||||
format!("Deleted tag {branch} in {repository}"),
|
||||
),
|
||||
Some(OpType::DeleteBranch) => (
|
||||
"branch-delete",
|
||||
format!("Deleted branch {branch} in {repository}"),
|
||||
),
|
||||
Some(OpType::PublishRelease) => ("release", format!("Published a release in {repository}")),
|
||||
Some(_) | None => ("update", format!("Updated {repository}")),
|
||||
};
|
||||
let target = activity::target(activity);
|
||||
let (target, owner, repository, number, sha) = match target {
|
||||
Some(activity::Target::Repository { owner, repository }) => {
|
||||
("repository", owner, repository, 0, String::new())
|
||||
}
|
||||
Some(activity::Target::Issue {
|
||||
owner,
|
||||
repository,
|
||||
number,
|
||||
}) => ("issue", owner, repository, number, String::new()),
|
||||
Some(activity::Target::Pull {
|
||||
owner,
|
||||
repository,
|
||||
number,
|
||||
}) => ("pull", owner, repository, number, String::new()),
|
||||
Some(activity::Target::Commit {
|
||||
owner,
|
||||
repository,
|
||||
sha,
|
||||
}) => ("commit", owner, repository, 0, sha),
|
||||
None => ("", String::new(), String::new(), 0, String::new()),
|
||||
};
|
||||
ActivityRow {
|
||||
icon: icon.into(),
|
||||
title,
|
||||
detail: activity_detail(activity),
|
||||
meta: compact_date(activity.created.as_deref()),
|
||||
target: target.into(),
|
||||
owner,
|
||||
repository,
|
||||
number,
|
||||
sha,
|
||||
}
|
||||
}
|
||||
|
||||
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 contribution_count = counts.iter().sum();
|
||||
let cells = counts
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, count)| HeatCell {
|
||||
week: (index / 7) as u32,
|
||||
day: (index % 7) as u32,
|
||||
level: if count == 0 || maximum == 0 {
|
||||
0
|
||||
} else {
|
||||
((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
(cells, contribution_count)
|
||||
}
|
||||
|
||||
fn label_row(label: &models::Label) -> Option<LabelRow> {
|
||||
let name = label.name.as_deref()?.trim();
|
||||
let color = label.color.as_deref()?.trim().trim_start_matches('#');
|
||||
if name.is_empty() || color.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let value = u32::from_str_radix(color, 16).ok()?;
|
||||
let red = (value >> 16) & 0xff;
|
||||
let green = (value >> 8) & 0xff;
|
||||
let blue = value & 0xff;
|
||||
Some(LabelRow {
|
||||
name: name.into(),
|
||||
color: format!("#{color}"),
|
||||
light: red * 299 + green * 587 + blue * 114 > 150_000,
|
||||
})
|
||||
}
|
||||
|
||||
fn comment_row(comment: &models::Comment) -> CommentRow {
|
||||
CommentRow {
|
||||
author: comment
|
||||
.user
|
||||
.as_ref()
|
||||
.and_then(|user| user.login.as_deref())
|
||||
.or(comment.original_author.as_deref())
|
||||
.unwrap_or("unknown")
|
||||
.into(),
|
||||
body: comment
|
||||
.body
|
||||
.clone()
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or_else(|| "No comment text.".into()),
|
||||
meta: compact_date(
|
||||
comment
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.or(comment.created_at.as_deref()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_row(file: &models::ChangedFile) -> Option<FileRow> {
|
||||
Some(FileRow {
|
||||
path: file.filename.clone()?,
|
||||
status: file.status.clone().unwrap_or_else(|| "modified".into()),
|
||||
})
|
||||
}
|
||||
|
||||
fn pull_files_ref(pull: &models::PullRequest) -> String {
|
||||
if pull.state.as_deref() == Some("open") {
|
||||
let branch = pull
|
||||
.head
|
||||
.as_ref()
|
||||
.and_then(|head| head.r#ref.as_deref().or(head.label.as_deref()))
|
||||
.unwrap_or("head branch");
|
||||
format!("Files on {branch}")
|
||||
} else if let Some(sha) = pull.merge_commit_sha.as_deref() {
|
||||
format!("Files at merge commit {}", &sha[..sha.len().min(8)])
|
||||
} else {
|
||||
"Files from pull request".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn issue_meta(issue: &models::Issue) -> String {
|
||||
let author = issue
|
||||
.user
|
||||
.as_ref()
|
||||
.and_then(|user| user.login.as_deref())
|
||||
.unwrap_or("unknown");
|
||||
format!(
|
||||
"#{} · {} · updated {} · {} comments",
|
||||
issue.number.unwrap_or_default(),
|
||||
author,
|
||||
compact_date(issue.updated_at.as_deref()),
|
||||
issue.comments.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
fn issue_milestone(issue: &models::Issue) -> String {
|
||||
issue
|
||||
.milestone
|
||||
.as_ref()
|
||||
.and_then(|milestone| milestone.title.as_deref())
|
||||
.unwrap_or_default()
|
||||
.into()
|
||||
}
|
||||
|
||||
fn milestone_row(milestone: &models::Milestone) -> MilestoneRow {
|
||||
let open = milestone.open_issues.unwrap_or_default();
|
||||
let closed = milestone.closed_issues.unwrap_or_default();
|
||||
let due = milestone
|
||||
.due_on
|
||||
.as_deref()
|
||||
.map(|date| format!("due {}", compact_date(Some(date))))
|
||||
.unwrap_or_else(|| "no due date".into());
|
||||
MilestoneRow {
|
||||
id: milestone.id.unwrap_or_default(),
|
||||
title: milestone
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Untitled milestone".into()),
|
||||
description: milestone
|
||||
.description
|
||||
.clone()
|
||||
.filter(|description| !description.is_empty())
|
||||
.unwrap_or_else(|| "No description".into()),
|
||||
meta: format!(
|
||||
"{} · {closed} of {} closed · {due}",
|
||||
milestone.state.as_deref().unwrap_or("unknown"),
|
||||
open + closed
|
||||
),
|
||||
open_issues: open,
|
||||
closed_issues: closed,
|
||||
}
|
||||
}
|
||||
|
||||
fn summary(body: &str) -> String {
|
||||
body.split_whitespace()
|
||||
.take(24)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn indices(values: &[usize]) -> Vec<u32> {
|
||||
values.iter().map(|value| *value as u32).collect()
|
||||
}
|
||||
|
||||
pub fn compact_date(date: Option<&str>) -> String {
|
||||
date.and_then(|date| date.get(..10))
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_heat_levels_and_labels() {
|
||||
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());
|
||||
|
||||
let milestone = models::Milestone {
|
||||
id: Some(1),
|
||||
title: Some("Version 1".into()),
|
||||
open_issues: Some(3),
|
||||
closed_issues: Some(2),
|
||||
state: Some("open".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let milestone_row = milestone_rows(std::slice::from_ref(&milestone)).remove(0);
|
||||
assert_eq!(
|
||||
(milestone_row.open_issues, milestone_row.closed_issues),
|
||||
(3, 2)
|
||||
);
|
||||
let issue = models::Issue {
|
||||
milestone: Some(Box::new(milestone)),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(issue_rows(&[issue])[0].milestone, "Version 1");
|
||||
}
|
||||
}
|
||||
@@ -96,31 +96,10 @@ mod tests {
|
||||
"https://gitea.example.com|octo/demo"
|
||||
);
|
||||
assert_eq!(Preferences::default().pull_status, "open");
|
||||
assert_eq!(
|
||||
Preferences::default().icon_style,
|
||||
crate::domain::IconStyle::Outline
|
||||
);
|
||||
assert_eq!(
|
||||
Preferences::default().appearance,
|
||||
crate::domain::AppearanceMode::Auto
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Preferences>(r#"{"icon_style":"duotone"}"#)
|
||||
.unwrap()
|
||||
.icon_style,
|
||||
crate::domain::IconStyle::Duotone
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Preferences>(r#"{"icon_style":"future-style"}"#)
|
||||
.unwrap()
|
||||
.icon_style,
|
||||
crate::domain::IconStyle::Outline
|
||||
);
|
||||
assert_eq!(
|
||||
crate::domain::IconStyle::from_index(2),
|
||||
Some(crate::domain::IconStyle::Bold)
|
||||
);
|
||||
assert_eq!(crate::domain::IconStyle::from_index(3), None);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Preferences>(r#"{"appearance":"dark"}"#)
|
||||
.unwrap()
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
slint::include_modules!();
|
||||
@@ -1,627 +0,0 @@
|
||||
use gotcha_gitea::models;
|
||||
use slint::Color;
|
||||
|
||||
use crate::{
|
||||
activity,
|
||||
domain::{HistoryCommit, HomeData, IssueDetails, Preferences, PullDetails, State},
|
||||
markdown, model,
|
||||
storage::favorite_key,
|
||||
ui::*,
|
||||
};
|
||||
|
||||
pub fn refresh_servers(ui: &AppWindow, preferences: &Preferences) {
|
||||
ui.set_servers(model(
|
||||
preferences
|
||||
.servers
|
||||
.iter()
|
||||
.map(|server| ServerRow {
|
||||
name: server.name.clone().into(),
|
||||
url: server.url.clone().into(),
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn refresh_repositories(ui: &AppWindow, state: &State) {
|
||||
let server_url = state
|
||||
.active_server
|
||||
.and_then(|i| state.preferences.servers.get(i))
|
||||
.map(|server| server.url.as_str())
|
||||
.unwrap_or_default();
|
||||
let mut repositories = state.repositories.clone();
|
||||
repositories.sort_by_key(|repository| {
|
||||
(
|
||||
!state.preferences.favorites.contains(&favorite_key(
|
||||
server_url,
|
||||
&repository.owner,
|
||||
&repository.name,
|
||||
)),
|
||||
repository.name.to_lowercase(),
|
||||
)
|
||||
});
|
||||
ui.set_repositories(model(
|
||||
repositories
|
||||
.into_iter()
|
||||
.map(|repository| {
|
||||
let favorite = state.preferences.favorites.contains(&favorite_key(
|
||||
server_url,
|
||||
&repository.owner,
|
||||
&repository.name,
|
||||
));
|
||||
RepositoryRow {
|
||||
name: repository.name.into(),
|
||||
owner: repository.owner.into(),
|
||||
description: repository.description.into(),
|
||||
meta: format!(
|
||||
"{} · {} open · {}",
|
||||
repository.language, repository.open_issues, repository.updated
|
||||
)
|
||||
.into(),
|
||||
favorite,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
ui.set_repositories_scroll_request(ui.get_repositories_scroll_request().wrapping_add(1));
|
||||
}
|
||||
|
||||
pub fn refresh_issues(ui: &AppWindow, issues: &[models::Issue]) {
|
||||
ui.set_issues(model(
|
||||
issues
|
||||
.iter()
|
||||
.map(|issue| IssueRow {
|
||||
number: issue.number.unwrap_or_default() as i32,
|
||||
title: issue
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Untitled issue".into())
|
||||
.into(),
|
||||
summary: issue
|
||||
.body
|
||||
.as_deref()
|
||||
.map(summary)
|
||||
.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));
|
||||
}
|
||||
|
||||
pub fn refresh_pulls(ui: &AppWindow, pulls: &[models::Issue]) {
|
||||
ui.set_pulls(model(
|
||||
pulls
|
||||
.iter()
|
||||
.filter_map(|pull| {
|
||||
let repository = pull.repository.as_ref()?;
|
||||
let owner = repository.owner.clone()?;
|
||||
let name = repository.name.clone()?;
|
||||
let author = pull
|
||||
.user
|
||||
.as_ref()
|
||||
.and_then(|user| user.login.as_deref())
|
||||
.unwrap_or("unknown");
|
||||
let kind = if pull
|
||||
.pull_request
|
||||
.as_ref()
|
||||
.and_then(|pull| pull.draft)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"Draft"
|
||||
} else {
|
||||
pull.state.as_deref().unwrap_or("unknown")
|
||||
};
|
||||
Some(PullRow {
|
||||
number: pull.number.unwrap_or_default() as i32,
|
||||
owner: owner.into(),
|
||||
repository: name.into(),
|
||||
title: pull
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Untitled pull request".into())
|
||||
.into(),
|
||||
summary: pull
|
||||
.body
|
||||
.as_deref()
|
||||
.map(summary)
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or_else(|| "No description".into())
|
||||
.into(),
|
||||
meta: format!(
|
||||
"{kind} · {author} · {} · {} comments",
|
||||
compact_date(pull.updated_at.as_deref()),
|
||||
pull.comments.unwrap_or_default()
|
||||
)
|
||||
.into(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn refresh_commits(ui: &AppWindow, commits: &[HistoryCommit], lane_count: usize) {
|
||||
ui.set_commits(model(
|
||||
commits
|
||||
.iter()
|
||||
.filter_map(|row| {
|
||||
let commit = &row.commit;
|
||||
let sha = commit.sha.as_deref()?;
|
||||
let details = commit.commit.as_ref();
|
||||
let author = details
|
||||
.and_then(|commit| commit.author.as_ref())
|
||||
.and_then(|author| author.name.as_deref())
|
||||
.or_else(|| {
|
||||
commit
|
||||
.author
|
||||
.as_ref()
|
||||
.and_then(|author| author.login.as_deref())
|
||||
})
|
||||
.unwrap_or("unknown");
|
||||
let date = details
|
||||
.and_then(|commit| commit.author.as_ref())
|
||||
.and_then(|author| author.date.as_deref())
|
||||
.or(commit.created.as_deref());
|
||||
Some(CommitRow {
|
||||
sha: sha.into(),
|
||||
title: details
|
||||
.and_then(|commit| commit.message.as_deref())
|
||||
.map(|message| message.lines().next().unwrap_or(message))
|
||||
.unwrap_or("Commit")
|
||||
.into(),
|
||||
meta: format!("{author} · {}", compact_date(date)).into(),
|
||||
refs: row.refs.join(" · ").into(),
|
||||
lanes: model(
|
||||
(0..lane_count)
|
||||
.map(|lane| GraphLane {
|
||||
color: Color::from_hsva(
|
||||
(lane as f32 * 137.508) % 360.0,
|
||||
0.78,
|
||||
0.78,
|
||||
1.0,
|
||||
),
|
||||
top: row.top_lanes.contains(&lane),
|
||||
bottom: row.bottom_lanes.contains(&lane),
|
||||
node: row.node_lane == Some(lane),
|
||||
connect_left: row.connections.contains(&lane),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub 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() => (
|
||||
"branch-create",
|
||||
format!("Created branch {branch} in {repository}"),
|
||||
),
|
||||
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => {
|
||||
("push", format!("Pushed to {branch} in {repository}"))
|
||||
}
|
||||
Some(OpType::CreateRepo) => ("repo-create", format!("Created {repository}")),
|
||||
Some(OpType::RenameRepo) => ("repo-rename", format!("Renamed {repository}")),
|
||||
Some(OpType::StarRepo) => ("repo-star", format!("Starred {repository}")),
|
||||
Some(OpType::WatchRepo) => ("repo-watch", format!("Started watching {repository}")),
|
||||
Some(OpType::CreateIssue) => ("issue-open", format!("Opened an issue in {repository}")),
|
||||
Some(OpType::CloseIssue) => ("issue-close", format!("Closed an issue in {repository}")),
|
||||
Some(OpType::ReopenIssue) => ("issue-reopen", format!("Reopened an issue in {repository}")),
|
||||
Some(OpType::CommentIssue) => (
|
||||
"issue-comment",
|
||||
format!("Commented on an issue in {repository}"),
|
||||
),
|
||||
Some(OpType::CreatePullRequest) => (
|
||||
"pull-open",
|
||||
format!("Opened a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => (
|
||||
"pull-merge",
|
||||
format!("Merged a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::ClosePullRequest) => (
|
||||
"pull-close",
|
||||
format!("Closed a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::ReopenPullRequest) => (
|
||||
"pull-reopen",
|
||||
format!("Reopened a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::CommentPull) => (
|
||||
"pull-comment",
|
||||
format!("Commented on a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::ApprovePullRequest) => (
|
||||
"pull-approve",
|
||||
format!("Approved a pull request in {repository}"),
|
||||
),
|
||||
Some(OpType::RejectPullRequest) => {
|
||||
("pull-reject", format!("Requested changes in {repository}"))
|
||||
}
|
||||
Some(OpType::PushTag) => ("tag-push", format!("Pushed tag {branch} in {repository}")),
|
||||
Some(OpType::DeleteTag) => (
|
||||
"tag-delete",
|
||||
format!("Deleted tag {branch} in {repository}"),
|
||||
),
|
||||
Some(OpType::DeleteBranch) => (
|
||||
"branch-delete",
|
||||
format!("Deleted branch {branch} in {repository}"),
|
||||
),
|
||||
Some(OpType::PublishRelease) => ("release", format!("Published a release in {repository}")),
|
||||
Some(_) | None => ("update", format!("Updated {repository}")),
|
||||
};
|
||||
let target = activity::target(activity);
|
||||
let (target, owner, repository, number, sha) = match target {
|
||||
Some(activity::Target::Repository { owner, repository }) => {
|
||||
("repository", owner, repository, 0, String::new())
|
||||
}
|
||||
Some(activity::Target::Issue {
|
||||
owner,
|
||||
repository,
|
||||
number,
|
||||
}) => ("issue", owner, repository, number, String::new()),
|
||||
Some(activity::Target::Pull {
|
||||
owner,
|
||||
repository,
|
||||
number,
|
||||
}) => ("pull", owner, repository, number, String::new()),
|
||||
Some(activity::Target::Commit {
|
||||
owner,
|
||||
repository,
|
||||
sha,
|
||||
}) => ("commit", owner, repository, 0, sha),
|
||||
None => ("", String::new(), String::new(), 0, String::new()),
|
||||
};
|
||||
ActivityRow {
|
||||
icon: icon.into(),
|
||||
title: title.into(),
|
||||
detail: activity_detail(activity).into(),
|
||||
meta: compact_date(activity.created.as_deref()).into(),
|
||||
target: target.into(),
|
||||
owner: owner.into(),
|
||||
repository: repository.into(),
|
||||
number: number.min(i32::MAX as i64) as i32,
|
||||
sha: sha.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)
|
||||
}
|
||||
|
||||
pub fn show_issue(ui: &AppWindow, details: &IssueDetails) {
|
||||
let issue = &details.issue;
|
||||
ui.set_issue_title(
|
||||
issue
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Untitled issue".into())
|
||||
.into(),
|
||||
);
|
||||
ui.set_issue_meta(issue_meta(issue).into());
|
||||
ui.set_issue_body(model(markdown::render(
|
||||
issue
|
||||
.body
|
||||
.as_deref()
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or("No description provided."),
|
||||
)));
|
||||
ui.set_comments(model(details.comments.iter().map(comment_row).collect()));
|
||||
ui.set_issue_scroll_request(ui.get_issue_scroll_request().wrapping_add(1));
|
||||
ui.set_page("issue".into());
|
||||
}
|
||||
|
||||
pub fn show_pull(ui: &AppWindow, details: &PullDetails) {
|
||||
let pull = &details.pull;
|
||||
let author = pull
|
||||
.user
|
||||
.as_ref()
|
||||
.and_then(|user| user.login.as_deref())
|
||||
.unwrap_or("unknown");
|
||||
let head = pull
|
||||
.head
|
||||
.as_ref()
|
||||
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
|
||||
.unwrap_or("head");
|
||||
let base = pull
|
||||
.base
|
||||
.as_ref()
|
||||
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
|
||||
.unwrap_or("base");
|
||||
let state = if pull.merged.unwrap_or(false) {
|
||||
"merged"
|
||||
} else if pull.draft.unwrap_or(false) {
|
||||
"draft"
|
||||
} else {
|
||||
pull.state.as_deref().unwrap_or("unknown")
|
||||
};
|
||||
ui.set_detail_title(
|
||||
pull.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Untitled pull request".into())
|
||||
.into(),
|
||||
);
|
||||
ui.set_detail_meta(
|
||||
format!(
|
||||
"#{} · {state} · {author} · {head} → {base}\n{} files · +{} −{} · {} comments",
|
||||
pull.number.unwrap_or_default(),
|
||||
pull.changed_files.unwrap_or_default(),
|
||||
pull.additions.unwrap_or_default(),
|
||||
pull.deletions.unwrap_or_default(),
|
||||
pull.comments.unwrap_or_default(),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
ui.set_detail_body(model(markdown::render(
|
||||
pull.body
|
||||
.as_deref()
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or("No description provided."),
|
||||
)));
|
||||
ui.set_comments(model(details.comments.iter().map(comment_row).collect()));
|
||||
ui.set_files(model(
|
||||
details
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|file| {
|
||||
Some(FileRow {
|
||||
path: file.filename.clone()?.into(),
|
||||
status: file
|
||||
.status
|
||||
.clone()
|
||||
.unwrap_or_else(|| "modified".into())
|
||||
.into(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
ui.set_detail_files_ref(pull_files_ref(pull).into());
|
||||
ui.set_page("pull".into());
|
||||
}
|
||||
|
||||
fn pull_files_ref(pull: &models::PullRequest) -> String {
|
||||
if pull.state.as_deref() == Some("open") {
|
||||
let branch = pull
|
||||
.head
|
||||
.as_ref()
|
||||
.and_then(|head| head.r#ref.as_deref().or(head.label.as_deref()))
|
||||
.unwrap_or("head branch");
|
||||
format!("Files on {branch}")
|
||||
} else if let Some(sha) = pull.merge_commit_sha.as_deref() {
|
||||
format!("Files at merge commit {}", &sha[..sha.len().min(8)])
|
||||
} else {
|
||||
"Files from pull request".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn comment_row(comment: &models::Comment) -> CommentRow {
|
||||
CommentRow {
|
||||
author: comment
|
||||
.user
|
||||
.as_ref()
|
||||
.and_then(|user| user.login.as_deref())
|
||||
.or(comment.original_author.as_deref())
|
||||
.unwrap_or("unknown")
|
||||
.into(),
|
||||
body: model(markdown::render(
|
||||
comment
|
||||
.body
|
||||
.as_deref()
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or("No comment text."),
|
||||
)),
|
||||
meta: compact_date(
|
||||
comment
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.or(comment.created_at.as_deref()),
|
||||
)
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn issue_meta(issue: &models::Issue) -> String {
|
||||
let author = issue
|
||||
.user
|
||||
.as_ref()
|
||||
.and_then(|user| user.login.as_deref())
|
||||
.unwrap_or("unknown");
|
||||
format!(
|
||||
"#{} · {} · updated {} · {} comments",
|
||||
issue.number.unwrap_or_default(),
|
||||
author,
|
||||
compact_date(issue.updated_at.as_deref()),
|
||||
issue.comments.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
fn summary(body: &str) -> String {
|
||||
body.split_whitespace()
|
||||
.take(24)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub fn compact_date(date: Option<&str>) -> String {
|
||||
date.and_then(|date| date.get(..10))
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_heat_levels_and_labels() {
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_files_use_the_live_branch_or_merge_commit() {
|
||||
let mut pull = models::PullRequest {
|
||||
state: Some("open".into()),
|
||||
head: Some(Box::new(models::PrBranchInfo {
|
||||
r#ref: Some("feature/files".into()),
|
||||
..Default::default()
|
||||
})),
|
||||
merge_commit_sha: Some("0123456789abcdef".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(pull_files_ref(&pull), "Files on feature/files");
|
||||
|
||||
pull.state = Some("closed".into());
|
||||
assert_eq!(pull_files_ref(&pull), "Files at merge commit 01234567");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_rows_use_semantic_icons() {
|
||||
let activity = models::Activity {
|
||||
op_type: Some(models::activity::OpType::CreatePullRequest),
|
||||
content: Some("42|feature".into()),
|
||||
repo: Some(Box::new(models::Repository {
|
||||
full_name: Some("octo/demo".into()),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(activity_row(&activity).icon, "pull-open");
|
||||
}
|
||||
}
|
||||
@@ -1,614 +0,0 @@
|
||||
import { Button, ComboBox, LineEdit, ListView, ScrollView, Spinner } from "std-widgets.slint";
|
||||
export {
|
||||
ActivityRow, CommentRow, CommitRow, DiffLine, FileRow, GraphLane, HeatCell, IssueRow,
|
||||
LabelRow, MarkdownBlock, PullRow, RepositoryRow, ServerRow,
|
||||
} from "types.slint";
|
||||
import {
|
||||
ActivityRow, CommentRow, CommitRow, DiffLine, FileRow, GraphLane, HeatCell, IssueRow,
|
||||
MarkdownBlock, PullRow, RepositoryRow, ServerRow,
|
||||
} from "types.slint";
|
||||
export { AppTheme, IconTheme } from "components.slint";
|
||||
import {
|
||||
AppearanceChoice, AppIcon, AppTheme, CommentCard, EdgeBack, EmptyState, FileCard, Header,
|
||||
IconStyleChoice, MarkdownContent, PullToRefreshGesture, TabButton,
|
||||
} from "components.slint";
|
||||
|
||||
export component AppWindow inherits Window {
|
||||
title: "Gotcha";
|
||||
preferred-width: 390px;
|
||||
preferred-height: 844px;
|
||||
background: AppTheme.page;
|
||||
private property <length> toolbar-height: 68px;
|
||||
|
||||
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 <[PullRow]> pulls;
|
||||
in-out property <[CommentRow]> comments;
|
||||
in-out property <[CommitRow]> commits;
|
||||
in-out property <[FileRow]> files;
|
||||
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 <[MarkdownBlock]> issue_body;
|
||||
in-out property <string> detail_title;
|
||||
in-out property <string> detail_meta;
|
||||
in-out property <string> detail_files_ref;
|
||||
in-out property <[MarkdownBlock]> detail_body;
|
||||
in-out property <[DiffLine]> diff_lines;
|
||||
in-out property <int> diff_columns;
|
||||
in-out property <string> issue_filter: "open";
|
||||
in-out property <string> pull_filter: "open";
|
||||
in-out property <[string]> branch_choices;
|
||||
in-out property <int> branch_index;
|
||||
in-out property <bool> filter_open;
|
||||
in-out property <bool> loading;
|
||||
in-out property <bool> home_loading;
|
||||
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 refresh_pulls();
|
||||
callback refresh_pull();
|
||||
callback refresh_commits();
|
||||
callback show_add_server();
|
||||
callback save_server(string, string, string);
|
||||
callback select_server(int);
|
||||
callback select_repository(string, string);
|
||||
callback toggle_favorite(string, string);
|
||||
callback select_issue(int);
|
||||
callback select_pull(string, string, int);
|
||||
callback select_commit(string);
|
||||
callback select_branch(string);
|
||||
callback select_file(string);
|
||||
callback select_pull_file(string);
|
||||
callback select_activity(string, string, string, int, string);
|
||||
callback choose_filter(string);
|
||||
callback choose_icon_style(int);
|
||||
callback choose_appearance(int);
|
||||
callback back();
|
||||
|
||||
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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
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: AppTheme.surface; border-radius: 12px;
|
||||
Text { x: 13px; y: 10px; text: "Activity · last 12 months"; font-size: 13px; font-weight: 600; color: AppTheme.text; }
|
||||
Text { x: 13px; y: 86px; text: root.contribution_count + " contributions"; font-size: 12px; color: AppTheme.text-secondary; }
|
||||
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 ? (AppTheme.dark ? #3a3a3c : #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: AppTheme.surface; border-radius: 12px;
|
||||
TouchArea {
|
||||
enabled: activity.target != "";
|
||||
clicked => { root.select_activity(activity.target, activity.owner, activity.repository, activity.number, activity.sha); }
|
||||
}
|
||||
Rectangle {
|
||||
x: 12px; y: 14px; width: 34px; height: 34px; border-radius: 17px;
|
||||
background: AppTheme.accent-soft;
|
||||
AppIcon { x: 5px; y: 5px; width: 24px; height: 24px; kind: activity.icon; }
|
||||
}
|
||||
Text { x: 57px; y: 11px; width: parent.width - 69px; height: 24px; text: activity.title; font-size: 15px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
||||
Text { x: 57px; y: 36px; width: parent.width - 69px; height: 35px; text: activity.detail; font-size: 13px; color: AppTheme.text-secondary; wrap: word-wrap; overflow: elide; }
|
||||
Text { x: 57px; y: 72px; width: parent.width - 69px; text: activity.meta; font-size: 11px; color: AppTheme.text-tertiary; overflow: elide; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.home_loading: Spinner { width: 34px; height: 34px; x: (parent.width - self.width) / 2; y: 222px; }
|
||||
}
|
||||
|
||||
if 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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
Header {
|
||||
x: 0; y: 0; can_back: false;
|
||||
title: "Settings";
|
||||
}
|
||||
Rectangle {
|
||||
x: 16px; y: 76px; width: parent.width - 32px; height: 184px;
|
||||
background: AppTheme.surface; border-radius: 12px;
|
||||
Text { x: 15px; y: 13px; text: "Icon style"; font-size: 18px; font-weight: 650; color: AppTheme.text; }
|
||||
Text { x: 15px; y: 43px; width: parent.width - 30px; text: "Choose how navigation and activity icons look throughout Gotcha."; font-size: 13px; color: AppTheme.text-secondary; wrap: word-wrap; }
|
||||
IconStyleChoice { x: 12px; y: 91px; width: (parent.width - 36px) / 3; height: 78px; value: 0; label: "Outline"; clicked => { root.choose_icon_style(0); } }
|
||||
IconStyleChoice { x: 18px + (parent.width - 36px) / 3; y: 91px; width: (parent.width - 36px) / 3; height: 78px; value: 1; label: "Duotone"; clicked => { root.choose_icon_style(1); } }
|
||||
IconStyleChoice { x: 24px + 2 * (parent.width - 36px) / 3; y: 91px; width: (parent.width - 36px) / 3; height: 78px; value: 2; label: "Bold"; clicked => { root.choose_icon_style(2); } }
|
||||
}
|
||||
Rectangle {
|
||||
x: 16px; y: 276px; width: parent.width - 32px; height: 136px;
|
||||
background: AppTheme.surface; border-radius: 12px;
|
||||
Text { x: 15px; y: 13px; text: "Appearance"; font-size: 18px; font-weight: 650; color: AppTheme.text; }
|
||||
Text { x: 15px; y: 43px; width: parent.width - 30px; text: "Follow iOS automatically or choose a fixed light or dark appearance."; font-size: 13px; color: AppTheme.text-secondary; wrap: word-wrap; }
|
||||
AppearanceChoice { x: 12px; y: 81px; width: (parent.width - 36px) / 3; height: 42px; value: 0; label: "Auto"; clicked => { root.choose_appearance(0); } }
|
||||
AppearanceChoice { x: 18px + (parent.width - 36px) / 3; y: 81px; width: (parent.width - 36px) / 3; height: 42px; value: 1; label: "Light"; clicked => { root.choose_appearance(1); } }
|
||||
AppearanceChoice { x: 24px + 2 * (parent.width - 36px) / 3; y: 81px; width: (parent.width - 36px) / 3; height: 42px; value: 2; label: "Dark"; clicked => { root.choose_appearance(2); } }
|
||||
}
|
||||
}
|
||||
|
||||
if (tab == "issues" || tab == "repositories") && 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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
Header { x: 0; y: 0; title: "Servers"; can_back: false; }
|
||||
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: AppTheme.surface;
|
||||
border-color: AppTheme.border;
|
||||
border-width: 1px;
|
||||
border-radius: 12px;
|
||||
TouchArea { clicked => { root.select_server(index); } }
|
||||
Text { x: 16px; y: 13px; width: parent.width - 54px; text: server.name; font-size: 17px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
||||
Text { x: 16px; y: 42px; width: parent.width - 54px; text: server.url; font-size: 13px; color: AppTheme.text-secondary; overflow: elide; }
|
||||
AppIcon { x: parent.width - 29px; y: 26px; width: 20px; height: 20px; kind: "disclosure"; icon-color: AppTheme.text-tertiary; }
|
||||
}
|
||||
}
|
||||
Button { x: 16px; y: parent.height - 66px; width: parent.width - 32px; height: 48px; text: "Add Server"; clicked => { root.show_add_server(); } }
|
||||
}
|
||||
|
||||
if (tab == "issues" || tab == "repositories") && 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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
Header { x: 0; y: 0; title: "Add Server"; can_back: true; back => { root.back(); } }
|
||||
VerticalLayout {
|
||||
x: 20px; y: 92px; width: parent.width - 40px; height: 260px; spacing: 10px;
|
||||
Text { text: "Name"; font-size: 13px; color: AppTheme.text-secondary; }
|
||||
server_name := LineEdit { placeholder-text: "Work"; }
|
||||
Text { text: "Server URL"; font-size: 13px; color: AppTheme.text-secondary; }
|
||||
server_url := LineEdit { placeholder-text: "https://gitea.example.com"; }
|
||||
Text { text: "Access token"; font-size: 13px; color: AppTheme.text-secondary; }
|
||||
server_token := LineEdit { placeholder-text: "Token"; input-type: InputType.password; }
|
||||
}
|
||||
Button {
|
||||
x: 20px; y: 380px; width: parent.width - 40px; height: 48px;
|
||||
text: root.loading ? "Connecting…" : "Add Server";
|
||||
enabled: !root.loading;
|
||||
clicked => { root.save_server(server_name.text, server_url.text, server_token.text); }
|
||||
}
|
||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||
}
|
||||
|
||||
if (tab == "issues" || tab == "repositories") && 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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
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.";
|
||||
}
|
||||
PullToRefreshGesture {
|
||||
x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px;
|
||||
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: AppTheme.surface;
|
||||
border-color: AppTheme.border;
|
||||
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: AppTheme.text; overflow: elide; }
|
||||
Text { x: 15px; y: 39px; width: parent.width - 30px; text: repo.description; font-size: 14px; color: AppTheme.text-secondary; overflow: elide; }
|
||||
Text { x: 15px; y: 71px; width: parent.width - 30px; text: repo.meta; font-size: 12px; color: AppTheme.text-tertiary; overflow: elide; }
|
||||
TouchArea {
|
||||
x: parent.width - 52px; y: 2px; width: 48px; height: 48px;
|
||||
clicked => { root.toggle_favorite(repo.owner, repo.name); }
|
||||
AppIcon { x: 10px; y: 10px; width: 28px; height: 28px; kind: repo.favorite ? "favorite-filled" : "favorite"; icon-color: repo.favorite ? #f2a900 : AppTheme.text-tertiary; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||
}
|
||||
|
||||
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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
Header {
|
||||
x: 0; y: 0; title: root.repository_title; can_back: true; can_filter: true;
|
||||
back => { root.back(); }
|
||||
filter => { root.filter_open = true; }
|
||||
}
|
||||
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 " + root.issue_filter + " issues";
|
||||
detail: "No issues match the selected status.";
|
||||
}
|
||||
PullToRefreshGesture {
|
||||
x: 20px; y: 70px; width: parent.width - 40px; height: parent.height - 82px;
|
||||
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: AppTheme.surface;
|
||||
border-color: AppTheme.border;
|
||||
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: AppTheme.text; overflow: elide; }
|
||||
Text { x: 15px; y: 39px; width: parent.width - 30px; height: 38px; text: issue.summary; font-size: 13px; color: AppTheme.text-secondary; 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: AppTheme.text-tertiary; overflow: elide; }
|
||||
}
|
||||
}
|
||||
}
|
||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||
}
|
||||
|
||||
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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
Header { x: 0; y: 0; title: "Issue"; can_back: true; back => { root.back(); } }
|
||||
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;
|
||||
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: AppTheme.text; wrap: word-wrap; }
|
||||
Text { text: root.issue_meta; font-size: 13px; color: AppTheme.text-secondary; wrap: word-wrap; }
|
||||
Rectangle { height: 1px; background: AppTheme.separator; }
|
||||
MarkdownContent { width: parent.width; blocks: root.issue_body; }
|
||||
if root.comments.length > 0: Text { text: "Comments"; font-size: 17px; font-weight: 600; color: AppTheme.text; }
|
||||
for comment in root.comments: CommentCard { width: parent.width; comment: comment; }
|
||||
}
|
||||
}
|
||||
}
|
||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||
}
|
||||
|
||||
if tab == "pulls" && !root.has_active_server: 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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
Header { x: 0; y: 0; title: "Home"; can_back: false; }
|
||||
EmptyState {
|
||||
x: 32px; width: parent.width - 64px; y: 210px; height: 190px;
|
||||
title: "No server selected";
|
||||
detail: "Open Issues or Repositories to select a server or add your first one.";
|
||||
}
|
||||
}
|
||||
|
||||
if tab == "pulls" && root.has_active_server && page == "pulls": 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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
Header {
|
||||
x: 0; y: 0; title: "Pull Requests"; can_back: false; can_filter: true;
|
||||
filter => { root.filter_open = true; }
|
||||
}
|
||||
if root.refreshing: Spinner { x: parent.width - 92px; y: 17px; width: 24px; height: 24px; }
|
||||
if !root.loading && root.pulls.length == 0: EmptyState {
|
||||
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
|
||||
title: "No " + root.pull_filter + " pull requests";
|
||||
detail: "No pull requests match the selected status.";
|
||||
}
|
||||
PullToRefreshGesture {
|
||||
x: 16px; y: 70px; width: parent.width - 32px; height: parent.height - 82px;
|
||||
at_top: root.issues_at_top; refreshing: root.refreshing;
|
||||
refresh => { root.refresh_pulls(); }
|
||||
ListView {
|
||||
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
||||
scrolled => { root.issues_at_top = self.viewport-y >= 0px; }
|
||||
for pull in root.pulls: Rectangle {
|
||||
height: 112px;
|
||||
background: AppTheme.surface; border-color: AppTheme.border; border-width: 1px; border-radius: 12px;
|
||||
TouchArea { clicked => { root.select_pull(pull.owner, pull.repository, pull.number); } }
|
||||
Text { x: 14px; y: 10px; width: parent.width - 28px; text: pull.repository + " #" + pull.number; font-size: 12px; color: AppTheme.text-secondary; overflow: elide; }
|
||||
Text { x: 14px; y: 31px; width: parent.width - 28px; text: pull.title; font-size: 16px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
||||
Text { x: 14px; y: 57px; width: parent.width - 28px; text: pull.summary; font-size: 13px; color: AppTheme.text-secondary; overflow: elide; }
|
||||
Text { x: 14px; y: 85px; width: parent.width - 28px; text: pull.meta; font-size: 12px; color: AppTheme.text-tertiary; overflow: elide; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tab == "pulls" && page == "pull": 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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
Header { x: 0; y: 0; title: "Pull Request"; can_back: true; back => { root.back(); } }
|
||||
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;
|
||||
at_top: root.issue_at_top; refreshing: root.refreshing;
|
||||
refresh => { root.refresh_pull(); }
|
||||
ScrollView {
|
||||
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
||||
scrolled => { root.issue_at_top = self.viewport-y >= 0px; }
|
||||
VerticalLayout {
|
||||
width: parent.width; spacing: 12px; alignment: start;
|
||||
Text { text: root.detail_title; font-size: 23px; font-weight: 650; color: AppTheme.text; wrap: word-wrap; }
|
||||
Text { text: root.detail_meta; font-size: 13px; color: AppTheme.text-secondary; wrap: word-wrap; }
|
||||
Rectangle { height: 1px; background: AppTheme.separator; }
|
||||
MarkdownContent { width: parent.width; blocks: root.detail_body; }
|
||||
if root.files.length > 0: Text { text: root.detail_files_ref; font-size: 17px; font-weight: 600; color: AppTheme.text; }
|
||||
for file in root.files: FileCard { width: parent.width; file: file; clicked => { root.select_pull_file(file.path); } }
|
||||
if root.comments.length > 0: Text { text: "Comments"; font-size: 17px; font-weight: 600; color: AppTheme.text; }
|
||||
for comment in root.comments: CommentCard { width: parent.width; comment: comment; }
|
||||
}
|
||||
}
|
||||
}
|
||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||
}
|
||||
|
||||
if tab == "repositories" && page == "commits": 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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
Header { x: 0; y: 0; title: root.repository_title; can_back: true; back => { root.back(); } }
|
||||
ComboBox {
|
||||
x: 16px; y: 66px; width: parent.width - 32px; height: 42px;
|
||||
model: root.branch_choices; current-index: root.branch_index;
|
||||
selected(value) => { root.select_branch(value); }
|
||||
}
|
||||
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
|
||||
if !root.loading && root.commits.length == 0: EmptyState {
|
||||
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
|
||||
title: "No commits";
|
||||
detail: "This repository has no commit history.";
|
||||
}
|
||||
PullToRefreshGesture {
|
||||
x: 16px; y: 116px; width: parent.width - 32px; height: parent.height - 128px;
|
||||
at_top: root.repositories_at_top; refreshing: root.refreshing;
|
||||
refresh => { root.refresh_commits(); }
|
||||
ListView {
|
||||
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
||||
scrolled => { root.repositories_at_top = self.viewport-y >= 0px; }
|
||||
for commit in root.commits: commit_row := Rectangle {
|
||||
property <length> graph-width: commit.lanes.length > 0 ? min(72px, commit.lanes.length * 10px + 8px) : 0px;
|
||||
property <length> lane-spacing: (self.graph-width - 8px) / max(1, commit.lanes.length);
|
||||
height: 86px; background: transparent;
|
||||
Rectangle { width: 100%; height: 100%; background: AppTheme.surface; border-color: AppTheme.border; border-width: 1px; border-radius: 12px; }
|
||||
TouchArea { clicked => { root.select_commit(commit.sha); } }
|
||||
for lane[index] in commit.lanes: Rectangle {
|
||||
x: 4px + index * commit_row.lane-spacing;
|
||||
width: 3px; height: 100%; background: transparent;
|
||||
if lane.top: Rectangle {
|
||||
y: -1px; width: 3px;
|
||||
height: lane.connect_left && !lane.bottom ? 37px : 42px;
|
||||
background: lane.color;
|
||||
}
|
||||
if lane.bottom: Rectangle {
|
||||
y: lane.connect_left && !lane.top ? 45px : 40px;
|
||||
width: 3px;
|
||||
height: parent.height - (lane.connect_left && !lane.top ? 44px : 39px);
|
||||
background: lane.color;
|
||||
}
|
||||
if lane.connect_left && lane.top == lane.bottom: Rectangle {
|
||||
x: -commit_row.lane-spacing; y: 39px;
|
||||
width: commit_row.lane-spacing + 3px; height: 3px;
|
||||
background: lane.color;
|
||||
}
|
||||
if lane.connect_left && lane.bottom && !lane.top: Path {
|
||||
x: 1.5px - commit_row.lane-spacing; y: 35px;
|
||||
width: commit_row.lane-spacing; height: 11px;
|
||||
viewbox-width: 10; viewbox-height: 11;
|
||||
commands: "M 0 5.5 L 5 5.5 Q 10 5.5 10 10.5";
|
||||
fill: transparent; stroke: lane.color; stroke-width: 3px; stroke-line-cap: round;
|
||||
}
|
||||
if lane.connect_left && lane.top && !lane.bottom: Path {
|
||||
x: 1.5px - commit_row.lane-spacing; y: 35px;
|
||||
width: commit_row.lane-spacing; height: 11px;
|
||||
viewbox-width: 10; viewbox-height: 11;
|
||||
commands: "M 10 0.5 Q 10 5.5 5 5.5 L 0 5.5";
|
||||
fill: transparent; stroke: lane.color; stroke-width: 3px; stroke-line-cap: round;
|
||||
}
|
||||
if lane.node: Rectangle {
|
||||
x: -3px; y: 36px; width: 9px; height: 9px; border-radius: 5px;
|
||||
background: lane.color; border-color: AppTheme.surface; border-width: 2px;
|
||||
}
|
||||
}
|
||||
Text { x: parent.graph-width + 14px; y: 8px; width: parent.width - self.x - 14px; text: commit.title; font-size: 15px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
||||
Text { x: parent.graph-width + 14px; y: 33px; width: parent.width - self.x - 14px; text: commit.refs != "" ? commit.refs : commit.meta; font-size: 12px; color: commit.refs != "" ? AppTheme.accent : AppTheme.text-secondary; overflow: elide; }
|
||||
Text { x: parent.graph-width + 14px; y: 53px; width: parent.width - self.x - 14px; text: commit.refs != "" ? commit.meta + " · " + commit.sha : commit.sha; font-size: 11px; color: AppTheme.text-tertiary; overflow: elide; }
|
||||
}
|
||||
}
|
||||
}
|
||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||
}
|
||||
|
||||
if tab == "repositories" && page == "files": 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 - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
Header { x: 0; y: 0; title: "Changed Files"; can_back: true; back => { root.back(); } }
|
||||
if !root.loading && root.files.length == 0: EmptyState {
|
||||
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
|
||||
title: "No changed files";
|
||||
detail: "This commit does not contain file changes.";
|
||||
}
|
||||
ListView {
|
||||
x: 16px; y: 70px; width: parent.width - 32px; height: parent.height - 82px; mouse-drag-pan-enabled: true;
|
||||
for file in root.files: FileCard { file: file; clicked => { root.select_file(file.path); } }
|
||||
}
|
||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||
}
|
||||
|
||||
if (tab == "repositories" || tab == "pulls") && page == "diff": Rectangle {
|
||||
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
||||
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - root.toolbar-height;
|
||||
background: AppTheme.page;
|
||||
Header { x: 0; y: 0; title: root.detail_title; can_back: true; back => { root.back(); } }
|
||||
ScrollView {
|
||||
x: 12px; y: 70px; width: parent.width - 24px; height: parent.height - 82px; mouse-drag-pan-enabled: true;
|
||||
viewport-width: max(self.visible-width, root.diff_columns * 7px + 100px);
|
||||
viewport-height: diff_rows.preferred-height;
|
||||
diff_rows := VerticalLayout {
|
||||
width: parent.viewport-width;
|
||||
for line in root.diff_lines: Rectangle {
|
||||
height: 20px;
|
||||
background: line.kind == "addition" ? (AppTheme.dark ? #173d25 : #ddf5e3) : line.kind == "removal" ? (AppTheme.dark ? #4a2020 : #fde2e1) : line.kind == "hunk" ? (AppTheme.dark ? #17324d : #dcecff) : line.kind == "header" ? AppTheme.surface-muted : AppTheme.surface;
|
||||
Text { x: 0; width: 38px; text: line.old_number; font-family: "monospace"; font-size: 11px; color: AppTheme.text-tertiary; horizontal-alignment: right; vertical-alignment: center; }
|
||||
Text { x: 42px; width: 38px; text: line.new_number; font-family: "monospace"; font-size: 11px; color: AppTheme.text-tertiary; horizontal-alignment: right; vertical-alignment: center; }
|
||||
Rectangle { x: 84px; width: 1px; height: 100%; background: AppTheme.separator; }
|
||||
Text {
|
||||
x: 90px; width: parent.width - 94px; text: line.text; font-family: "monospace"; font-size: 11px;
|
||||
color: line.kind == "addition" ? (AppTheme.dark ? #7fe787 : #176b2c) : line.kind == "removal" ? (AppTheme.dark ? #ff7b72 : #a12720) : line.kind == "hunk" ? (AppTheme.dark ? #79c0ff : #175b9e) : AppTheme.text;
|
||||
wrap: no-wrap; overflow: clip; vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: root.safe-area-insets.left;
|
||||
y: root.height - root.safe-area-insets.bottom - root.toolbar-height;
|
||||
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
||||
height: root.toolbar-height;
|
||||
background: AppTheme.toolbar;
|
||||
TabButton {
|
||||
x: 0; width: parent.width / 5; height: parent.height; kind: "home"; 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; kind: "issues"; 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; kind: "repositories"; label: "Repos"; active: root.tab == "repositories"; clicked => { root.select_tab("repositories"); } }
|
||||
TabButton { x: parent.width * 3 / 5; width: parent.width / 5; height: parent.height; kind: "pulls"; label: "Pulls"; active: root.tab == "pulls"; clicked => { root.select_tab("pulls"); } }
|
||||
TabButton { x: parent.width * 4 / 5; width: parent.width / 5; height: parent.height; kind: "settings"; label: "Settings"; active: root.tab == "settings"; clicked => { root.select_tab("settings"); } }
|
||||
}
|
||||
|
||||
if root.loading && (root.tab == "issues" || root.tab == "repositories" || root.tab == "pulls"): Rectangle {
|
||||
width: 100%; height: 100%;
|
||||
background: AppTheme.loading-overlay;
|
||||
TouchArea { }
|
||||
Spinner { width: 38px; height: 38px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
|
||||
}
|
||||
if root.filter_open: Rectangle {
|
||||
width: 100%; height: 100%; background: #00000066;
|
||||
TouchArea { clicked => { root.filter_open = false; } }
|
||||
Rectangle {
|
||||
x: 28px; y: (parent.height - 190px) / 2; width: parent.width - 56px; height: 190px;
|
||||
background: AppTheme.surface; border-radius: 14px;
|
||||
Text { x: 18px; y: 16px; width: parent.width - 36px; text: "Status"; font-size: 19px; font-weight: 650; color: AppTheme.text; }
|
||||
VerticalLayout {
|
||||
x: 18px; y: 54px; width: parent.width - 36px; height: 118px; spacing: 8px;
|
||||
Button { text: "Open"; clicked => { root.choose_filter("open"); root.filter_open = false; } }
|
||||
Button { text: "Closed"; clicked => { root.choose_filter("closed"); root.filter_open = false; } }
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.error != "": Rectangle {
|
||||
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 = ""; } }
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import { Palette } from "std-widgets.slint";
|
||||
import { CommentRow, FileRow, MarkdownBlock } from "types.slint";
|
||||
|
||||
export global IconTheme {
|
||||
in-out property <int> style: 0;
|
||||
}
|
||||
|
||||
export global AppTheme {
|
||||
in-out property <int> mode: 0;
|
||||
private property <color> system-background: Palette.background;
|
||||
out property <bool> dark: mode == 2 || (mode == 0 && system-background.to-oklch().lightness < 0.5);
|
||||
out property <color> accent: dark ? #0a84ff : #0879e1;
|
||||
out property <color> accent-soft: dark ? #17324d : #e5f2fd;
|
||||
out property <color> page: dark ? #1c1c1e : #f2f2f7;
|
||||
out property <color> surface: dark ? #2c2c2e : #ffffff;
|
||||
out property <color> surface-muted: dark ? #3a3a3c : #f5f5f8;
|
||||
out property <color> header: dark ? #242426 : #f8f8fa;
|
||||
out property <color> toolbar: dark ? #242426 : #fbfbfc;
|
||||
out property <color> border: dark ? #48484a : #dedee3;
|
||||
out property <color> separator: dark ? #3a3a3c : #d8d8dd;
|
||||
out property <color> text: dark ? #f2f2f7 : #17171a;
|
||||
out property <color> text-secondary: dark ? #c7c7cc : #5f5f66;
|
||||
out property <color> text-tertiary: dark ? #8e8e93 : #85858c;
|
||||
out property <color> code-background: dark ? #242426 : #e9e9ed;
|
||||
out property <color> quote-background: dark ? #242426 : #f6f6f8;
|
||||
out property <color> loading-overlay: dark ? #00000088 : #ffffff88;
|
||||
changed mode => {
|
||||
Palette.color-scheme = mode == 1 ? ColorScheme.light : mode == 2 ? ColorScheme.dark : ColorScheme.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export component AppIcon inherits Rectangle {
|
||||
in property <string> kind;
|
||||
in property <int> style: IconTheme.style;
|
||||
in property <color> icon-color: AppTheme.accent;
|
||||
background: transparent;
|
||||
|
||||
private property <bool> duotone: root.style == 1;
|
||||
private property <bool> bold: root.style == 2;
|
||||
private property <bool> issue-kind: root.kind == "issues" || root.kind == "issue-open" || root.kind == "issue-close" || root.kind == "issue-reopen" || root.kind == "issue-comment";
|
||||
private property <bool> repository-kind: root.kind == "repositories" || root.kind == "repo-create" || root.kind == "repo-rename" || root.kind == "repo-star" || root.kind == "repo-watch" || root.kind == "update";
|
||||
private property <bool> pull-kind: root.kind == "pulls" || root.kind == "pull-open" || root.kind == "pull-merge" || root.kind == "pull-close" || root.kind == "pull-reopen" || root.kind == "pull-comment" || root.kind == "pull-approve" || root.kind == "pull-reject";
|
||||
private property <bool> tag-kind: root.kind == "tag-push" || root.kind == "tag-delete";
|
||||
private property <bool> branch-kind: root.kind == "branch-create" || root.kind == "branch-delete";
|
||||
private property <bool> closed-shape: root.kind == "home" || root.issue-kind || root.repository-kind || root.kind == "favorite" || root.kind == "favorite-filled" || root.tag-kind || root.kind == "release";
|
||||
private property <bool> has-badge: root.kind == "branch-create" || root.kind == "branch-delete" || root.kind == "push" || root.kind == "repo-create" || root.kind == "repo-rename" || root.kind == "repo-star" || root.kind == "repo-watch" || root.kind == "update" || root.kind == "issue-open" || root.kind == "issue-close" || root.kind == "issue-reopen" || root.kind == "issue-comment" || root.kind == "pull-open" || root.kind == "pull-merge" || root.kind == "pull-close" || root.kind == "pull-reopen" || root.kind == "pull-comment" || root.kind == "pull-approve" || root.kind == "pull-reject" || root.kind == "tag-push" || root.kind == "tag-delete";
|
||||
private property <bool> positive: root.kind == "issue-close" || root.kind == "pull-merge" || root.kind == "pull-approve";
|
||||
private property <bool> destructive: root.kind == "branch-delete" || root.kind == "pull-close" || root.kind == "pull-reject" || root.kind == "tag-delete";
|
||||
private property <color> badge-color: root.positive ? #20a866 : root.destructive ? #e45b4d : root.duotone ? #26bfd1 : root.icon-color;
|
||||
private property <string> shape: root.kind == "home"
|
||||
? "M3 10.5 L12 3 L21 10.5 V21 H15 V14 H9 V21 H3 Z"
|
||||
: root.issue-kind
|
||||
? "M4 5 H20 V9 C18 9 18 15 20 15 V19 H4 V15 C6 15 6 9 4 9 Z"
|
||||
: root.repository-kind
|
||||
? "M6 3 H18 C19.1 3 20 3.9 20 5 V21 H6 C4.9 21 4 20.1 4 19 V5 C4 3.9 4.9 3 6 3 Z"
|
||||
: root.pull-kind
|
||||
? "M7 4 V20 M17 4 V11 C17 15 14 17 10 17 M7 4 H7 M17 4 H17"
|
||||
: root.kind == "settings"
|
||||
? "M12 3 V6 M12 18 V21 M3 12 H6 M18 12 H21 M5.6 5.6 L7.8 7.8 M16.2 16.2 L18.4 18.4 M18.4 5.6 L16.2 7.8 M7.8 16.2 L5.6 18.4"
|
||||
: root.kind == "back"
|
||||
? (root.bold ? "M16 4 L7 12 L16 20 Z" : "M16 4 L7 12 L16 20")
|
||||
: root.kind == "filter"
|
||||
? (root.bold ? "M3 5 H21 L14 13 V20 L10 22 V13 Z" : "M4 6 H20 M4 12 H20 M4 18 H20")
|
||||
: root.kind == "favorite" || root.kind == "favorite-filled"
|
||||
? "M12 3 L14.8 8.7 L21 9.6 L16.5 14 L17.6 20.3 L12 17.3 L6.4 20.3 L7.5 14 L3 9.6 L9.2 8.7 Z"
|
||||
: root.kind == "disclosure"
|
||||
? (root.bold ? "M8 4 L17 12 L8 20 Z" : "M8 4 L17 12 L8 20")
|
||||
: root.branch-kind
|
||||
? "M7 4 V20 M7 11 C11 11 14 9 14 5"
|
||||
: root.kind == "push"
|
||||
? "M4 8 H13 M4 12 H13 M4 16 H13"
|
||||
: root.tag-kind
|
||||
? "M3 5 V13 L11 21 L21 11 L13 3 H5 C3.9 3 3 3.9 3 5 Z"
|
||||
: root.kind == "release"
|
||||
? "M4 8 L12 4 L20 8 V18 L12 22 L4 18 Z"
|
||||
: "M12 4 V20 M4 12 H20";
|
||||
private property <string> detail: root.kind == "home"
|
||||
? "M9 21 V14 H15 V21"
|
||||
: root.issue-kind
|
||||
? "M12 8 V13 M12 16.5 V17"
|
||||
: root.repository-kind
|
||||
? "M7 3 V21 M11 9 L8.5 12 L11 15 M15 9 L17.5 12 L15 15"
|
||||
: root.pull-kind
|
||||
? "M7 4 A1.8 1.8 0 1 1 6.99 4 M7 20 A1.8 1.8 0 1 1 6.99 20 M17 4 A1.8 1.8 0 1 1 16.99 4"
|
||||
: root.kind == "settings"
|
||||
? "M12 8 A4 4 0 1 1 11.99 8"
|
||||
: root.kind == "filter" && !root.bold
|
||||
? "M9 4 V8 M15 10 V14 M8 16 V20"
|
||||
: root.tag-kind
|
||||
? "M8 8 A1.5 1.5 0 1 1 7.99 8"
|
||||
: root.kind == "release"
|
||||
? "M12 18 V8 M9 11 L12 8 L15 11 M7 6 V3 M17 6 V3"
|
||||
: "";
|
||||
private property <string> badge-detail: root.kind == "branch-create" || root.kind == "repo-create" || root.kind == "issue-open" || root.kind == "pull-open"
|
||||
? "M18 15.5 V20.5 M15.5 18 H20.5"
|
||||
: root.kind == "issue-close" || root.kind == "pull-merge" || root.kind == "pull-approve"
|
||||
? "M15.4 18 L17.2 19.8 L20.7 16.2"
|
||||
: root.kind == "branch-delete" || root.kind == "tag-delete"
|
||||
? "M15.3 18 H20.7"
|
||||
: root.kind == "pull-close" || root.kind == "pull-reject"
|
||||
? "M16 16 L20 20 M20 16 L16 20"
|
||||
: root.kind == "push" || root.kind == "tag-push"
|
||||
? "M18 20.5 V15.5 M15.7 17.8 L18 15.5 L20.3 17.8"
|
||||
: root.kind == "repo-rename"
|
||||
? "M15.3 20.6 L16 18 L19.7 14.3 L21.7 16.3 L18 20 Z"
|
||||
: root.kind == "repo-star"
|
||||
? "M18 14.5 L19 16.7 L21.4 17 L19.6 18.7 L20.1 21 L18 19.8 L15.9 21 L16.4 18.7 L14.6 17 L17 16.7 Z"
|
||||
: root.kind == "repo-watch"
|
||||
? "M14.5 18 C16.3 15.8 19.7 15.8 21.5 18 C19.7 20.2 16.3 20.2 14.5 18 M18 17 A1 1 0 1 1 17.99 17"
|
||||
: root.kind == "issue-reopen" || root.kind == "pull-reopen"
|
||||
? "M20.8 17 A3 3 0 1 0 20.2 20.3 M20.8 17 H18.5 M20.8 17 V19.3"
|
||||
: root.kind == "issue-comment" || root.kind == "pull-comment"
|
||||
? "M15.5 17 H20.5 V20 H18 L16 21 V20 H15.5 Z M17 18.5 H17.1 M19 18.5 H19.1"
|
||||
: root.kind == "update"
|
||||
? "M18 14.8 L18.8 17.2 L21.2 18 L18.8 18.8 L18 21.2 L17.2 18.8 L14.8 18 L17.2 17.2 Z"
|
||||
: "";
|
||||
|
||||
Path {
|
||||
width: 100%; height: 100%; viewbox-width: 24; viewbox-height: 24;
|
||||
commands: root.shape;
|
||||
fill: root.closed-shape ? (root.bold || root.kind == "favorite-filled" ? root.icon-color : root.duotone ? #26bfd133 : transparent) : transparent;
|
||||
stroke: root.closed-shape && (root.bold || root.kind == "favorite-filled") ? transparent : root.icon-color;
|
||||
stroke-width: root.bold ? 3px : root.duotone ? 2.2px : 1.8px;
|
||||
stroke-line-cap: round; stroke-line-join: round;
|
||||
}
|
||||
if root.detail != "": Path {
|
||||
width: 100%; height: 100%; viewbox-width: 24; viewbox-height: 24;
|
||||
commands: root.detail; fill: transparent;
|
||||
stroke: root.bold && root.closed-shape ? #ffffff : root.icon-color;
|
||||
stroke-width: root.bold ? 2.4px : 1.8px;
|
||||
stroke-line-cap: round; stroke-line-join: round;
|
||||
}
|
||||
if root.has-badge: Path {
|
||||
width: 100%; height: 100%; viewbox-width: 24; viewbox-height: 24;
|
||||
commands: "M18 13.5 A4.5 4.5 0 1 1 17.99 13.5 Z";
|
||||
fill: root.style == 0 ? AppTheme.surface : root.badge-color;
|
||||
stroke: root.style == 0 ? root.badge-color : #ffffff;
|
||||
stroke-width: root.style == 2 ? 1.5px : 1.2px;
|
||||
}
|
||||
if root.has-badge: Path {
|
||||
width: 100%; height: 100%; viewbox-width: 24; viewbox-height: 24;
|
||||
commands: root.badge-detail; fill: root.style == 0 ? transparent : #ffffff;
|
||||
stroke: root.style == 0 ? root.badge-color : #ffffff;
|
||||
stroke-width: root.bold ? 2px : 1.6px;
|
||||
stroke-line-cap: round; stroke-line-join: round;
|
||||
}
|
||||
}
|
||||
|
||||
export component IconStyleChoice inherits Rectangle {
|
||||
in property <int> value;
|
||||
in property <string> label;
|
||||
callback clicked();
|
||||
background: IconTheme.style == root.value ? AppTheme.accent-soft : AppTheme.surface-muted;
|
||||
border-color: IconTheme.style == root.value ? AppTheme.accent : AppTheme.border;
|
||||
border-width: IconTheme.style == root.value ? 2px : 1px;
|
||||
border-radius: 11px;
|
||||
TouchArea { clicked => { root.clicked(); } }
|
||||
AppIcon { x: (parent.width - 30px) / 2; y: 10px; width: 30px; height: 30px; kind: "repositories"; style: root.value; }
|
||||
Text { y: 48px; width: 100%; text: root.label; font-size: 12px; font-weight: IconTheme.style == root.value ? 650 : 500; color: AppTheme.text; horizontal-alignment: center; }
|
||||
}
|
||||
|
||||
export component AppearanceChoice inherits Rectangle {
|
||||
in property <int> value;
|
||||
in property <string> label;
|
||||
callback clicked();
|
||||
background: AppTheme.mode == root.value ? AppTheme.accent-soft : AppTheme.surface-muted;
|
||||
border-color: AppTheme.mode == root.value ? AppTheme.accent : AppTheme.border;
|
||||
border-width: AppTheme.mode == root.value ? 2px : 1px;
|
||||
border-radius: 11px;
|
||||
TouchArea { clicked => { root.clicked(); } }
|
||||
Text {
|
||||
width: 100%; height: 100%; text: root.label;
|
||||
font-size: 14px; font-weight: AppTheme.mode == root.value ? 650 : 500;
|
||||
color: AppTheme.mode == root.value ? AppTheme.accent : AppTheme.text;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
export component Header inherits Rectangle {
|
||||
in property <string> title;
|
||||
in property <bool> can_back;
|
||||
in property <bool> can_filter: false;
|
||||
callback back();
|
||||
callback filter();
|
||||
height: 58px;
|
||||
width: 100%;
|
||||
background: AppTheme.header;
|
||||
border-color: AppTheme.border;
|
||||
border-width: 0px;
|
||||
|
||||
if can_back: TouchArea {
|
||||
x: 8px; width: 54px;
|
||||
clicked => { root.back(); }
|
||||
AppIcon { x: 7px; y: 17px; width: 24px; height: 24px; kind: "back"; }
|
||||
}
|
||||
Text {
|
||||
x: root.can_back || root.can_filter ? 62px : 12px;
|
||||
width: parent.width - 2 * self.x;
|
||||
text: root.title;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: AppTheme.text;
|
||||
overflow: elide;
|
||||
}
|
||||
if can_filter: TouchArea {
|
||||
x: parent.width - 58px; width: 58px;
|
||||
clicked => { root.filter(); }
|
||||
AppIcon { x: 17px; y: 17px; width: 24px; height: 24px; kind: "filter"; }
|
||||
}
|
||||
Rectangle { y: parent.height - 1px; height: 1px; background: AppTheme.border; }
|
||||
}
|
||||
|
||||
export component EdgeBack inherits SwipeGestureHandler {
|
||||
callback back();
|
||||
width: 24px;
|
||||
height: 100%;
|
||||
handle-swipe-right: true;
|
||||
swiped => { root.back(); }
|
||||
}
|
||||
|
||||
export component EmptyState inherits VerticalLayout {
|
||||
in property <string> title;
|
||||
in property <string> detail;
|
||||
alignment: center;
|
||||
spacing: 8px;
|
||||
Text { text: root.title; font-size: 21px; font-weight: 600; horizontal-alignment: center; color: AppTheme.text; }
|
||||
Text { text: root.detail; font-size: 15px; horizontal-alignment: center; wrap: word-wrap; color: AppTheme.text-secondary; }
|
||||
}
|
||||
|
||||
export component MarkdownContent inherits VerticalLayout {
|
||||
in property <[MarkdownBlock]> blocks;
|
||||
spacing: 8px;
|
||||
|
||||
for block in root.blocks: Rectangle {
|
||||
height: block.kind == "rule" ? 9px : rendered.preferred-height + (block.kind == "code" || block.kind == "table" ? 16px : 0px);
|
||||
background: block.kind == "code" || block.kind == "table" ? AppTheme.code-background : block.kind == "quote" ? AppTheme.quote-background : transparent;
|
||||
border-radius: block.kind == "code" || block.kind == "table" ? 7px : 0px;
|
||||
if block.kind == "quote": Rectangle { width: 3px; height: 100%; background: AppTheme.text-tertiary; border-radius: 2px; }
|
||||
if block.kind == "rule": Rectangle { y: 4px; width: 100%; height: 1px; background: AppTheme.separator; }
|
||||
rendered := StyledText {
|
||||
x: block.kind == "code" || block.kind == "table" ? 8px : block.kind == "quote" ? 11px : 0px;
|
||||
y: block.kind == "code" || block.kind == "table" ? 8px : 0px;
|
||||
width: parent.width - self.x - (block.kind == "code" || block.kind == "table" ? 8px : 0px);
|
||||
text: block.text;
|
||||
default-color: AppTheme.text;
|
||||
link-color: AppTheme.accent;
|
||||
default-font-family: block.kind == "code" || block.kind == "table" ? "monospace" : "";
|
||||
default-font-size: block.kind == "heading1" ? 24px : block.kind == "heading2" ? 21px : block.kind == "heading3" ? 19px : block.kind == "heading4" ? 17px : 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component CommentCard inherits Rectangle {
|
||||
in property <CommentRow> comment;
|
||||
height: content.preferred-height + 24px;
|
||||
background: AppTheme.surface;
|
||||
border-radius: 10px;
|
||||
content := VerticalLayout {
|
||||
x: 12px; y: 10px; width: parent.width - 24px;
|
||||
spacing: 5px;
|
||||
Text { text: root.comment.author; font-size: 13px; font-weight: 600; color: AppTheme.text; }
|
||||
MarkdownContent { width: parent.width; blocks: root.comment.body; }
|
||||
Text { text: root.comment.meta; font-size: 11px; color: AppTheme.text-tertiary; }
|
||||
}
|
||||
}
|
||||
|
||||
export component FileCard inherits Rectangle {
|
||||
in property <FileRow> file;
|
||||
callback clicked();
|
||||
height: 66px;
|
||||
background: AppTheme.surface;
|
||||
border-color: AppTheme.border;
|
||||
border-width: 1px;
|
||||
border-radius: 10px;
|
||||
TouchArea { clicked => { root.clicked(); } }
|
||||
Text { x: 13px; y: 10px; width: parent.width - 26px; text: root.file.path; font-size: 14px; font-weight: 600; color: AppTheme.text; overflow: elide; }
|
||||
Text { x: 13px; y: 38px; width: parent.width - 26px; text: root.file.status; font-size: 12px; color: AppTheme.text-secondary; }
|
||||
}
|
||||
|
||||
export component TabButton inherits Rectangle {
|
||||
in property <string> kind;
|
||||
in property <string> label;
|
||||
in property <bool> active;
|
||||
private property <length> icon-area-height: root.height * 3 / 4;
|
||||
private property <length> icon-size: min(42px, self.icon-area-height - 6px);
|
||||
callback clicked();
|
||||
background: transparent;
|
||||
TouchArea { clicked => { root.clicked(); } }
|
||||
AppIcon {
|
||||
x: (parent.width - root.icon-size) / 2;
|
||||
y: (root.icon-area-height - root.icon-size) / 2;
|
||||
width: root.icon-size; height: root.icon-size;
|
||||
kind: root.kind; icon-color: root.active ? AppTheme.accent : AppTheme.text-tertiary;
|
||||
}
|
||||
Text {
|
||||
y: root.icon-area-height; height: parent.height - self.y; width: 100%; text: root.label;
|
||||
color: root.active ? AppTheme.accent : AppTheme.text-tertiary; font-size: 9px;
|
||||
font-weight: root.active ? 600 : 400;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
export 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: AppTheme.surface-muted; border-radius: 16px;
|
||||
Text { text: "Release to refresh"; font-size: 12px; color: AppTheme.text-secondary; horizontal-alignment: center; vertical-alignment: center; }
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
export struct ServerRow { name: string, url: string }
|
||||
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, labels: [LabelRow],
|
||||
}
|
||||
export struct PullRow {
|
||||
number: int, owner: string, repository: string, title: string, summary: string, meta: string,
|
||||
}
|
||||
export struct MarkdownBlock { text: styled-text, kind: string }
|
||||
export struct CommentRow { author: string, body: [MarkdownBlock], meta: string }
|
||||
export struct GraphLane { color: color, top: bool, bottom: bool, node: bool, connect_left: bool }
|
||||
export struct CommitRow {
|
||||
sha: string, title: string, meta: string, refs: string, lanes: [GraphLane],
|
||||
}
|
||||
export struct FileRow { path: string, status: string }
|
||||
export struct ActivityRow {
|
||||
icon: string, title: string, detail: string, meta: string,
|
||||
target: string, owner: string, repository: string, number: int, sha: string,
|
||||
}
|
||||
export struct DiffLine { old_number: string, new_number: string, text: string, kind: string }
|
||||
export struct HeatCell { week: int, day: int, level: int }
|
||||
Reference in New Issue
Block a user