Add pull request and repository browsing
This commit is contained in:
16
AGENTS.md
16
AGENTS.md
@@ -1,5 +1,21 @@
|
|||||||
# Repository instructions
|
# Repository instructions
|
||||||
|
|
||||||
|
## Required pre-commit gates
|
||||||
|
|
||||||
|
Run every gate below from the repository root before committing. Every command
|
||||||
|
must succeed; do not commit code that is unformatted, fails Clippy, or compiles
|
||||||
|
with warnings.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
RUSTFLAGS="-D warnings" cargo check --workspace --all-targets
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not weaken or skip these gates to make a commit pass. Fix the underlying
|
||||||
|
warning, lint, formatting issue, or test failure.
|
||||||
|
|
||||||
## iOS simulator build and deployment
|
## iOS simulator build and deployment
|
||||||
|
|
||||||
This Apple Silicon project builds the simulator app for `arm64`. Do not disable
|
This Apple Silicon project builds the simulator app for `arm64`. Do not disable
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1476,6 +1476,7 @@ name = "gotcha-app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gotcha_gitea",
|
"gotcha_gitea",
|
||||||
|
"pulldown-cmark",
|
||||||
"security-framework",
|
"security-framework",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gotcha_gitea = { path = "../gitea" }
|
gotcha_gitea = { path = "../gitea" }
|
||||||
|
pulldown-cmark = "0.13"
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
security-framework = "3"
|
security-framework = "3"
|
||||||
|
|||||||
156
crates/app/src/activity.rs
Normal file
156
crates/app/src/activity.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
use gotcha_gitea::models;
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
pub enum Target {
|
||||||
|
Commit {
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
sha: String,
|
||||||
|
},
|
||||||
|
Issue {
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
},
|
||||||
|
Pull {
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
number: i64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target(activity: &models::Activity) -> Option<Target> {
|
||||||
|
use models::activity::OpType;
|
||||||
|
|
||||||
|
let (owner, repository) = activity
|
||||||
|
.repo
|
||||||
|
.as_ref()?
|
||||||
|
.full_name
|
||||||
|
.as_deref()?
|
||||||
|
.split_once('/')?;
|
||||||
|
let pair = || (owner.to_string(), repository.to_string());
|
||||||
|
match activity.op_type? {
|
||||||
|
OpType::CommitRepo | OpType::MirrorSyncPush => {
|
||||||
|
let (owner, repository) = pair();
|
||||||
|
Some(Target::Commit {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
sha: commit_sha(activity.content.as_deref()?)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
OpType::CreateIssue | OpType::CloseIssue | OpType::ReopenIssue | OpType::CommentIssue => {
|
||||||
|
let (owner, repository) = pair();
|
||||||
|
Some(Target::Issue {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number: issue_number(activity)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
OpType::CreatePullRequest
|
||||||
|
| OpType::MergePullRequest
|
||||||
|
| OpType::AutoMergePullRequest
|
||||||
|
| OpType::ClosePullRequest
|
||||||
|
| OpType::ReopenPullRequest
|
||||||
|
| OpType::CommentPull
|
||||||
|
| OpType::ApprovePullRequest
|
||||||
|
| OpType::RejectPullRequest
|
||||||
|
| OpType::PullReviewDismissed
|
||||||
|
| OpType::PullRequestReadyForReview => {
|
||||||
|
let (owner, repository) = pair();
|
||||||
|
Some(Target::Pull {
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
number: issue_number(activity)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issue_number(activity: &models::Activity) -> Option<i64> {
|
||||||
|
activity
|
||||||
|
.comment
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|comment| {
|
||||||
|
comment
|
||||||
|
.pull_request_url
|
||||||
|
.as_deref()
|
||||||
|
.or(comment.issue_url.as_deref())
|
||||||
|
})
|
||||||
|
.and_then(url_number)
|
||||||
|
.or_else(|| {
|
||||||
|
activity
|
||||||
|
.content
|
||||||
|
.as_deref()?
|
||||||
|
.split(|character: char| !character.is_ascii_digit())
|
||||||
|
.find(|part| !part.is_empty())?
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_number(url: &str) -> Option<i64> {
|
||||||
|
url.trim_end_matches('/').rsplit('/').next()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_sha(content: &str) -> Option<String> {
|
||||||
|
let payload: serde_json::Value = serde_json::from_str(content).ok()?;
|
||||||
|
payload.get("HeadCommit").and_then(find_sha).or_else(|| {
|
||||||
|
payload
|
||||||
|
.get("Commits")?
|
||||||
|
.as_array()?
|
||||||
|
.last()
|
||||||
|
.and_then(find_sha)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_sha(value: &serde_json::Value) -> Option<String> {
|
||||||
|
value.as_object()?.iter().find_map(|(key, value)| {
|
||||||
|
(key.to_ascii_lowercase().contains("sha"))
|
||||||
|
.then(|| value.as_str())
|
||||||
|
.flatten()
|
||||||
|
.filter(|sha| sha.len() >= 7)
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use models::activity::OpType;
|
||||||
|
|
||||||
|
fn activity(op_type: OpType, content: &str) -> models::Activity {
|
||||||
|
models::Activity {
|
||||||
|
op_type: Some(op_type),
|
||||||
|
content: Some(content.into()),
|
||||||
|
repo: Some(Box::new(models::Repository {
|
||||||
|
full_name: Some("octo/demo".into()),
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_linkable_activity_targets() {
|
||||||
|
assert_eq!(
|
||||||
|
target(&activity(OpType::CreatePullRequest, "42|feature")),
|
||||||
|
Some(Target::Pull {
|
||||||
|
owner: "octo".into(),
|
||||||
|
repository: "demo".into(),
|
||||||
|
number: 42,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target(&activity(
|
||||||
|
OpType::CommitRepo,
|
||||||
|
r#"{"HeadCommit":{"Sha1":"0123456789abcdef"}}"#,
|
||||||
|
)),
|
||||||
|
Some(Target::Commit {
|
||||||
|
owner: "octo".into(),
|
||||||
|
repository: "demo".into(),
|
||||||
|
sha: "0123456789abcdef".into(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
crates/app/src/api.rs
Normal file
170
crates/app/src/api.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
use gotcha_gitea::{Client, apis, models};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{HomeData, RepositoryData, Server},
|
||||||
|
view::compact_date,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn load_repositories(server: &Server) -> Result<Vec<RepositoryData>, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
let configuration = client.configuration();
|
||||||
|
let user = client
|
||||||
|
.current_user()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let login = user.login.unwrap_or_default();
|
||||||
|
let repositories = apis::user_api::user_current_list_repos(&configuration, Some(1), Some(100))
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
Ok(repositories
|
||||||
|
.into_iter()
|
||||||
|
.filter(|repository| {
|
||||||
|
repository
|
||||||
|
.owner
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|owner| owner.login.as_deref())
|
||||||
|
== Some(login.as_str())
|
||||||
|
})
|
||||||
|
.filter_map(|repository| {
|
||||||
|
Some(RepositoryData {
|
||||||
|
owner: repository.owner?.login?,
|
||||||
|
name: repository.name?,
|
||||||
|
description: repository
|
||||||
|
.description
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.unwrap_or_else(|| "No description".into()),
|
||||||
|
language: repository
|
||||||
|
.language
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.unwrap_or_else(|| "Unknown language".into()),
|
||||||
|
open_issues: repository.open_issues_count.unwrap_or_default(),
|
||||||
|
updated: compact_date(repository.updated_at.as_deref()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_issues(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
status: &str,
|
||||||
|
) -> Result<Vec<models::Issue>, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
apis::issue_api::issue_list_issues(
|
||||||
|
&client.configuration(),
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
Some(status),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("issues"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(1),
|
||||||
|
Some(100),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
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())?;
|
||||||
|
let configuration = client.configuration();
|
||||||
|
let owner = client
|
||||||
|
.current_user()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.login
|
||||||
|
.ok_or("The server account has no username.")?;
|
||||||
|
let mut pulls = Vec::new();
|
||||||
|
for page in 1.. {
|
||||||
|
let batch = apis::issue_api::issue_search_issues(
|
||||||
|
&configuration,
|
||||||
|
Some(status),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("pulls"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&owner),
|
||||||
|
None,
|
||||||
|
Some(page),
|
||||||
|
Some(100),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if batch.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pulls.extend(batch);
|
||||||
|
}
|
||||||
|
Ok(pulls)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_commits(
|
||||||
|
server: &Server,
|
||||||
|
owner: &str,
|
||||||
|
repository: &str,
|
||||||
|
) -> Result<Vec<models::Commit>, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
apis::repository_api::repo_get_all_commits(
|
||||||
|
&client.configuration(),
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(false),
|
||||||
|
Some(1),
|
||||||
|
Some(100),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_home(server: &Server) -> Result<HomeData, String> {
|
||||||
|
let client =
|
||||||
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
||||||
|
let configuration = client.configuration();
|
||||||
|
let login = client
|
||||||
|
.current_user()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.login
|
||||||
|
.ok_or("The server account has no username.")?;
|
||||||
|
let (activities, heatmap) = tokio::join!(
|
||||||
|
apis::user_api::user_list_activity_feeds(
|
||||||
|
&configuration,
|
||||||
|
&login,
|
||||||
|
Some(true),
|
||||||
|
None,
|
||||||
|
Some(1),
|
||||||
|
Some(40),
|
||||||
|
),
|
||||||
|
apis::user_api::user_get_heatmap_data(&configuration, &login),
|
||||||
|
);
|
||||||
|
Ok(HomeData {
|
||||||
|
activities: activities.map_err(|error| error.to_string())?,
|
||||||
|
heatmap: heatmap.map_err(|error| error.to_string())?,
|
||||||
|
})
|
||||||
|
}
|
||||||
190
crates/app/src/diff.rs
Normal file
190
crates/app/src/diff.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
pub struct Line {
|
||||||
|
pub old_number: String,
|
||||||
|
pub new_number: String,
|
||||||
|
pub text: String,
|
||||||
|
pub kind: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Parsed {
|
||||||
|
pub lines: Vec<Line>,
|
||||||
|
pub columns: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_file(diff: &str, path: &str) -> Parsed {
|
||||||
|
let section = sections(diff)
|
||||||
|
.into_iter()
|
||||||
|
.find(|section| section_path(section).as_deref() == Some(path))
|
||||||
|
.unwrap_or("");
|
||||||
|
if section.is_empty() {
|
||||||
|
let text = format!("No diff data is available for {path}.");
|
||||||
|
return Parsed {
|
||||||
|
columns: text.chars().count(),
|
||||||
|
lines: vec![Line {
|
||||||
|
old_number: String::new(),
|
||||||
|
new_number: String::new(),
|
||||||
|
text,
|
||||||
|
kind: "header",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut old = 0;
|
||||||
|
let mut new = 0;
|
||||||
|
let mut columns = 0;
|
||||||
|
let lines = section
|
||||||
|
.lines()
|
||||||
|
.map(|text| {
|
||||||
|
columns = columns.max(display_columns(text));
|
||||||
|
let (old_number, new_number, kind) = if text.starts_with("@@") {
|
||||||
|
if let Some((old_start, new_start)) = hunk_starts(text) {
|
||||||
|
old = old_start;
|
||||||
|
new = new_start;
|
||||||
|
}
|
||||||
|
(String::new(), String::new(), "hunk")
|
||||||
|
} else if text.starts_with('+') && !text.starts_with("+++") {
|
||||||
|
let number = new.to_string();
|
||||||
|
new += 1;
|
||||||
|
(String::new(), number, "addition")
|
||||||
|
} else if text.starts_with('-') && !text.starts_with("---") {
|
||||||
|
let number = old.to_string();
|
||||||
|
old += 1;
|
||||||
|
(number, String::new(), "removal")
|
||||||
|
} else if text.starts_with(' ') {
|
||||||
|
let numbers = (old.to_string(), new.to_string());
|
||||||
|
old += 1;
|
||||||
|
new += 1;
|
||||||
|
(numbers.0, numbers.1, "context")
|
||||||
|
} else {
|
||||||
|
(String::new(), String::new(), "header")
|
||||||
|
};
|
||||||
|
Line {
|
||||||
|
old_number,
|
||||||
|
new_number,
|
||||||
|
text: text.into(),
|
||||||
|
kind,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Parsed { lines, columns }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sections(diff: &str) -> Vec<&str> {
|
||||||
|
let mut starts: Vec<_> = diff
|
||||||
|
.match_indices("diff --git ")
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
.collect();
|
||||||
|
starts.push(diff.len());
|
||||||
|
starts
|
||||||
|
.windows(2)
|
||||||
|
.map(move |bounds| &diff[bounds[0]..bounds[1]])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn section_path(section: &str) -> Option<String> {
|
||||||
|
section
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("+++ "))
|
||||||
|
.filter(|path| *path != "/dev/null")
|
||||||
|
.or_else(|| {
|
||||||
|
section
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("--- "))
|
||||||
|
.filter(|path| *path != "/dev/null")
|
||||||
|
})
|
||||||
|
.map(|path| {
|
||||||
|
decode_path(path)
|
||||||
|
.trim_start_matches("a/")
|
||||||
|
.trim_start_matches("b/")
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_path(path: &str) -> String {
|
||||||
|
let path = path.trim_matches('"');
|
||||||
|
let mut bytes = Vec::with_capacity(path.len());
|
||||||
|
let mut chars = path.bytes();
|
||||||
|
while let Some(byte) = chars.next() {
|
||||||
|
if byte != b'\\' {
|
||||||
|
bytes.push(byte);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(escaped) = chars.next() else { break };
|
||||||
|
match escaped {
|
||||||
|
b'n' => bytes.push(b'\n'),
|
||||||
|
b'r' => bytes.push(b'\r'),
|
||||||
|
b't' => bytes.push(b'\t'),
|
||||||
|
b'\\' | b'"' => bytes.push(escaped),
|
||||||
|
b'0'..=b'7' => {
|
||||||
|
let mut value = escaped - b'0';
|
||||||
|
for _ in 0..2 {
|
||||||
|
let Some(next) = chars.next() else { break };
|
||||||
|
if !(b'0'..=b'7').contains(&next) {
|
||||||
|
bytes.push(value);
|
||||||
|
bytes.push(next);
|
||||||
|
value = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
value = value * 8 + next - b'0';
|
||||||
|
}
|
||||||
|
if value != 0 {
|
||||||
|
bytes.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => bytes.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&bytes).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hunk_starts(line: &str) -> Option<(i32, i32)> {
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
(parts.next()? == "@@").then_some(())?;
|
||||||
|
Some((
|
||||||
|
range_start(parts.next()?, '-'),
|
||||||
|
range_start(parts.next()?, '+'),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range_start(value: &str, prefix: char) -> i32 {
|
||||||
|
value
|
||||||
|
.strip_prefix(prefix)
|
||||||
|
.and_then(|value| value.split(',').next())
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_columns(line: &str) -> usize {
|
||||||
|
line.chars().fold(0, |column, character| {
|
||||||
|
if character == '\t' {
|
||||||
|
(column / 8 + 1) * 8
|
||||||
|
} else {
|
||||||
|
column + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selects_and_numbers_a_file_diff() {
|
||||||
|
let diff = "diff --git a/one b/one\n--- a/one\n+++ b/one\n@@ -1 +1 @@\n-old\n+new\n\
|
||||||
|
diff --git a/two b/two\n--- a/two\n+++ b/two\n@@ -4,2 +8,3 @@\n context\n-removed\n+added\n";
|
||||||
|
let parsed = parse_file(diff, "two");
|
||||||
|
assert_eq!(parsed.lines[4].old_number, "4");
|
||||||
|
assert_eq!(parsed.lines[4].new_number, "8");
|
||||||
|
assert_eq!(parsed.lines[5].kind, "removal");
|
||||||
|
assert_eq!(parsed.lines[6].kind, "addition");
|
||||||
|
assert!(parsed.lines.iter().all(|line| !line.text.contains("old")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_git_quoted_paths() {
|
||||||
|
let diff = "diff --git \"a/caf\\303\\251\" \"b/caf\\303\\251\"\n--- \"a/caf\\303\\251\"\n+++ \"b/caf\\303\\251\"\n@@ -0,0 +1 @@\n+hello\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_file(diff, "café").lines.last().unwrap().kind,
|
||||||
|
"addition"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
crates/app/src/domain.rs
Normal file
82
crates/app/src/domain.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use gotcha_gitea::models;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Server {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct Preferences {
|
||||||
|
#[serde(default)]
|
||||||
|
pub servers: Vec<Server>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub favorites: BTreeSet<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_server: Option<usize>,
|
||||||
|
#[serde(default = "open_status")]
|
||||||
|
pub issue_status: String,
|
||||||
|
#[serde(default = "open_status")]
|
||||||
|
pub pull_status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Preferences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
servers: Vec::new(),
|
||||||
|
favorites: BTreeSet::new(),
|
||||||
|
last_server: None,
|
||||||
|
issue_status: open_status(),
|
||||||
|
pull_status: open_status(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RepositoryData {
|
||||||
|
pub owner: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub language: String,
|
||||||
|
pub open_issues: i64,
|
||||||
|
pub updated: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
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 commits: Vec<models::Commit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HomeData {
|
||||||
|
pub activities: Vec<models::Activity>,
|
||||||
|
pub heatmap: Vec<models::UserHeatmapData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct IssueDetails {
|
||||||
|
pub issue: models::Issue,
|
||||||
|
pub comments: Vec<models::Comment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PullDetails {
|
||||||
|
pub pull: models::PullRequest,
|
||||||
|
pub comments: Vec<models::Comment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_status() -> String {
|
||||||
|
"open".into()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
312
crates/app/src/markdown.rs
Normal file
312
crates/app/src/markdown.rs
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
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;"));
|
||||||
|
}
|
||||||
|
}
|
||||||
100
crates/app/src/storage.rs
Normal file
100
crates/app/src/storage.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use std::{env, fs, path::PathBuf};
|
||||||
|
|
||||||
|
use gotcha_gitea::Client;
|
||||||
|
use security_framework::passwords::{get_generic_password, set_generic_password};
|
||||||
|
|
||||||
|
use crate::domain::{Preferences, Server, open_status};
|
||||||
|
|
||||||
|
pub fn favorite_key(server: &str, owner: &str, repository: &str) -> String {
|
||||||
|
format!("{server}|{owner}/{repository}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_server(name: &str, url: &str, token: &str) -> Result<Server, String> {
|
||||||
|
let name = name.trim();
|
||||||
|
let url = url.trim().trim_end_matches('/');
|
||||||
|
let token = token.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Give this server a name.".into());
|
||||||
|
}
|
||||||
|
if token.is_empty() {
|
||||||
|
return Err("Enter an access token.".into());
|
||||||
|
}
|
||||||
|
Client::new(url, Some(token)).map_err(|error| error.to_string())?;
|
||||||
|
Ok(Server {
|
||||||
|
name: name.into(),
|
||||||
|
url: url.into(),
|
||||||
|
token: token.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_preferences() -> Result<Preferences, String> {
|
||||||
|
let path = preferences_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Preferences::default());
|
||||||
|
}
|
||||||
|
let mut preferences: Preferences =
|
||||||
|
serde_json::from_slice(&fs::read(&path).map_err(|error| error.to_string())?)
|
||||||
|
.map_err(|error| format!("Cannot read {}: {error}", path.display()))?;
|
||||||
|
if !matches!(preferences.issue_status.as_str(), "open" | "closed") {
|
||||||
|
preferences.issue_status = open_status();
|
||||||
|
}
|
||||||
|
if !matches!(preferences.pull_status.as_str(), "open" | "closed") {
|
||||||
|
preferences.pull_status = open_status();
|
||||||
|
}
|
||||||
|
for server in &mut preferences.servers {
|
||||||
|
server.token = String::from_utf8(
|
||||||
|
get_generic_password("de.rfc1437.gotcha", &keychain_account(server))
|
||||||
|
.map_err(|error| format!("Cannot read the token for {}: {error}", server.name))?,
|
||||||
|
)
|
||||||
|
.map_err(|_| format!("The token for {} is not valid text.", server.name))?;
|
||||||
|
}
|
||||||
|
Ok(preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_preferences(preferences: &Preferences) -> Result<(), String> {
|
||||||
|
let path = preferences_path()?;
|
||||||
|
let parent = path.parent().ok_or("Invalid app data directory.")?;
|
||||||
|
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
||||||
|
let temporary = path.with_extension("tmp");
|
||||||
|
fs::write(
|
||||||
|
&temporary,
|
||||||
|
serde_json::to_vec(preferences).map_err(|error| error.to_string())?,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
fs::rename(&temporary, &path).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_server_token(server: &Server) -> Result<(), String> {
|
||||||
|
set_generic_password(
|
||||||
|
"de.rfc1437.gotcha",
|
||||||
|
&keychain_account(server),
|
||||||
|
server.token.as_bytes(),
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("Cannot save the token for {}: {error}", server.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preferences_path() -> Result<PathBuf, String> {
|
||||||
|
let home = env::var_os("HOME").ok_or("Cannot find the app data directory.")?;
|
||||||
|
Ok(PathBuf::from(home).join("Library/Application Support/Gotcha/preferences.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keychain_account(server: &Server) -> String {
|
||||||
|
format!("{}|{}", server.name, server.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_servers_and_builds_stable_favorite_keys() {
|
||||||
|
assert!(validate_server("Work", "https://gitea.example.com/", "secret").is_ok());
|
||||||
|
assert!(validate_server("", "https://gitea.example.com", "secret").is_err());
|
||||||
|
assert!(validate_server("Work", "file:///tmp/gitea", "secret").is_err());
|
||||||
|
assert_eq!(
|
||||||
|
favorite_key("https://gitea.example.com", "octo", "demo"),
|
||||||
|
"https://gitea.example.com|octo/demo"
|
||||||
|
);
|
||||||
|
assert_eq!(Preferences::default().pull_status, "open");
|
||||||
|
}
|
||||||
|
}
|
||||||
687
crates/app/src/ui.rs
Normal file
687
crates/app/src/ui.rs
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
slint::slint! {
|
||||||
|
import { Button, LineEdit, ListView, ScrollView, Spinner } from "std-widgets.slint";
|
||||||
|
|
||||||
|
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 CommitRow { sha: string, title: string, meta: string }
|
||||||
|
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 }
|
||||||
|
|
||||||
|
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: #f8f8fa;
|
||||||
|
border-color: #dedee3;
|
||||||
|
border-width: 0px;
|
||||||
|
|
||||||
|
if can_back: TouchArea {
|
||||||
|
x: 8px; width: 54px;
|
||||||
|
clicked => { root.back(); }
|
||||||
|
Text { text: "‹"; color: #0879e1; font-size: 38px; vertical-alignment: center; }
|
||||||
|
}
|
||||||
|
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: #151518;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
if can_filter: TouchArea {
|
||||||
|
x: parent.width - 58px; width: 58px;
|
||||||
|
clicked => { root.filter(); }
|
||||||
|
Text { text: "☷"; color: #0879e1; font-size: 25px; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
|
}
|
||||||
|
Rectangle { y: parent.height - 1px; height: 1px; background: #dedee3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
component EdgeBack inherits SwipeGestureHandler {
|
||||||
|
callback back();
|
||||||
|
width: 24px;
|
||||||
|
height: 100%;
|
||||||
|
handle-swipe-right: true;
|
||||||
|
swiped => { root.back(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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: #1c1c1e; }
|
||||||
|
Text { text: root.detail; font-size: 15px; horizontal-alignment: center; wrap: word-wrap; color: #69696f; }
|
||||||
|
}
|
||||||
|
|
||||||
|
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" ? #e9e9ed : block.kind == "quote" ? #f6f6f8 : transparent;
|
||||||
|
border-radius: block.kind == "code" || block.kind == "table" ? 7px : 0px;
|
||||||
|
if block.kind == "quote": Rectangle { width: 3px; height: 100%; background: #9a9aa1; border-radius: 2px; }
|
||||||
|
if block.kind == "rule": Rectangle { y: 4px; width: 100%; height: 1px; background: #d2d2d7; }
|
||||||
|
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: #252529;
|
||||||
|
link-color: #0879e1;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component CommentCard inherits Rectangle {
|
||||||
|
in property <CommentRow> comment;
|
||||||
|
height: content.preferred-height + 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
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: #252529; }
|
||||||
|
MarkdownContent { width: parent.width; blocks: root.comment.body; }
|
||||||
|
Text { text: root.comment.meta; font-size: 11px; color: #85858b; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component TabButton inherits Rectangle {
|
||||||
|
in property <string> icon;
|
||||||
|
in property <string> label;
|
||||||
|
in property <bool> active;
|
||||||
|
callback clicked();
|
||||||
|
background: transparent;
|
||||||
|
TouchArea { clicked => { root.clicked(); } }
|
||||||
|
Text {
|
||||||
|
y: 4px; height: 29px; width: 100%; text: root.icon;
|
||||||
|
color: root.active ? #0879e1 : #77777d; font-size: 24px;
|
||||||
|
horizontal-alignment: center; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
y: 34px; height: 18px; width: 100%; text: root.label;
|
||||||
|
color: root.active ? #0879e1 : #77777d; font-size: 10px;
|
||||||
|
font-weight: root.active ? 600 : 400;
|
||||||
|
horizontal-alignment: center; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component PullToRefreshGesture inherits SwipeGestureHandler {
|
||||||
|
in property <bool> at_top;
|
||||||
|
in property <bool> refreshing;
|
||||||
|
callback refresh();
|
||||||
|
enabled: root.at_top && !root.refreshing;
|
||||||
|
handle-swipe-down: true;
|
||||||
|
swiped => { root.refresh(); }
|
||||||
|
@children
|
||||||
|
if self.swiping: Rectangle {
|
||||||
|
x: (parent.width - 142px) / 2; y: 8px; width: 142px; height: 32px;
|
||||||
|
background: #e9e9ee; border-radius: 16px;
|
||||||
|
Text { text: "Release to refresh"; font-size: 12px; color: #55555b; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component AppWindow inherits Window {
|
||||||
|
title: "Gotcha";
|
||||||
|
preferred-width: 390px;
|
||||||
|
preferred-height: 844px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
|
||||||
|
in-out property <string> tab: "home";
|
||||||
|
in-out property <string> page: "servers";
|
||||||
|
in-out property <[ServerRow]> servers;
|
||||||
|
in-out property <[RepositoryRow]> repositories;
|
||||||
|
in-out property <[IssueRow]> issues;
|
||||||
|
in-out property <[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 <[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 <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_file(string);
|
||||||
|
callback select_activity(string, string, string, int, string);
|
||||||
|
callback choose_filter(string);
|
||||||
|
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 - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
Header { x: 0; y: 0; title: root.has_active_server ? root.home_server : "Home"; can_back: false; }
|
||||||
|
|
||||||
|
if !root.has_active_server: EmptyState {
|
||||||
|
x: 32px; width: parent.width - 64px; y: 210px; height: 190px;
|
||||||
|
title: "No server selected";
|
||||||
|
detail: "Open Issues to select a server or add your first one.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.has_active_server: Rectangle {
|
||||||
|
x: 16px; y: 72px; width: parent.width - 32px; height: 118px;
|
||||||
|
background: #ffffff; border-radius: 12px;
|
||||||
|
Text { x: 13px; y: 10px; text: "Activity · last 12 months"; font-size: 13px; font-weight: 600; color: #38383d; }
|
||||||
|
Text { x: 13px; y: 86px; text: root.contribution_count + " contributions"; font-size: 12px; color: #74747a; }
|
||||||
|
for cell in root.heat_cells: Rectangle {
|
||||||
|
x: 13px + cell.week * 6.7px; y: 39px + cell.day * 6.2px;
|
||||||
|
width: 5.2px; height: 5.2px; border-radius: 1px;
|
||||||
|
background: cell.level == 0 ? #e5e5e9 : cell.level == 1 ? #b8d9f4 : cell.level == 2 ? #72b5e8 : cell.level == 3 ? #278bd4 : #0969b7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.has_active_server && !root.home_loading && root.activities.length == 0: EmptyState {
|
||||||
|
x: 32px; width: parent.width - 64px; y: 250px; height: 150px;
|
||||||
|
title: "No recent activity";
|
||||||
|
detail: "Your recent server actions will appear here.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.has_active_server: PullToRefreshGesture {
|
||||||
|
x: 16px; y: 202px; width: parent.width - 32px; height: parent.height - 214px;
|
||||||
|
at_top: root.home_at_top; refreshing: root.home_loading;
|
||||||
|
refresh => { root.refresh_home(); }
|
||||||
|
home_list := ListView {
|
||||||
|
property <int> scroll_request: root.home_scroll_request;
|
||||||
|
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
||||||
|
changed scroll_request => { self.viewport-y = 0px; root.home_at_top = true; }
|
||||||
|
scrolled => { root.home_at_top = self.viewport-y >= 0px; }
|
||||||
|
for activity in root.activities: Rectangle {
|
||||||
|
height: 94px;
|
||||||
|
background: #ffffff; border-radius: 12px;
|
||||||
|
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: #e5f2fd;
|
||||||
|
Text { text: activity.icon; color: #0879e1; font-size: 20px; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
|
}
|
||||||
|
Text { x: 57px; y: 11px; width: parent.width - 69px; height: 24px; text: activity.title; font-size: 15px; font-weight: 600; color: #19191d; overflow: elide; }
|
||||||
|
Text { x: 57px; y: 36px; width: parent.width - 69px; height: 35px; text: activity.detail; font-size: 13px; color: #55555c; wrap: word-wrap; overflow: elide; }
|
||||||
|
Text { x: 57px; y: 72px; width: parent.width - 69px; text: activity.meta; font-size: 11px; color: #898990; overflow: elide; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.home_loading: Spinner { width: 34px; height: 34px; x: (parent.width - self.width) / 2; y: 222px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if tab == "settings": Rectangle {
|
||||||
|
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
||||||
|
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
||||||
|
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
Header {
|
||||||
|
x: 0; y: 0; can_back: false;
|
||||||
|
title: "Settings";
|
||||||
|
}
|
||||||
|
EmptyState {
|
||||||
|
x: 32px; width: parent.width - 64px; y: 220px; height: 160px;
|
||||||
|
title: "Coming soon";
|
||||||
|
detail: "This section is ready for the next feature pass.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab == "issues" || 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 - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
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: #ffffff;
|
||||||
|
border-color: #dedee3;
|
||||||
|
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: #19191c; overflow: elide; }
|
||||||
|
Text { x: 16px; y: 42px; width: parent.width - 54px; text: server.url; font-size: 13px; color: #6c6c72; overflow: elide; }
|
||||||
|
Text { x: parent.width - 28px; text: "›"; font-size: 27px; color: #a0a0a6; vertical-alignment: center; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
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: #66666c; }
|
||||||
|
server_name := LineEdit { placeholder-text: "Work"; }
|
||||||
|
Text { text: "Server URL"; font-size: 13px; color: #66666c; }
|
||||||
|
server_url := LineEdit { placeholder-text: "https://gitea.example.com"; }
|
||||||
|
Text { text: "Access token"; font-size: 13px; color: #66666c; }
|
||||||
|
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 - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
Header { x: 0; y: 0; title: root.server_title; can_back: true; back => { root.back(); } }
|
||||||
|
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
|
||||||
|
if !root.loading && root.repositories.length == 0: EmptyState {
|
||||||
|
x: 34px; width: parent.width - 68px; y: 220px; height: 160px;
|
||||||
|
title: "No repositories";
|
||||||
|
detail: "This account does not own any repositories on this server.";
|
||||||
|
}
|
||||||
|
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: #ffffff;
|
||||||
|
border-color: #dedee3;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 12px;
|
||||||
|
TouchArea { clicked => { root.select_repository(repo.owner, repo.name); } }
|
||||||
|
Text { x: 15px; y: 11px; width: parent.width - 62px; text: repo.name; font-size: 17px; font-weight: 600; color: #17171a; overflow: elide; }
|
||||||
|
Text { x: 15px; y: 39px; width: parent.width - 30px; text: repo.description; font-size: 14px; color: #56565c; overflow: elide; }
|
||||||
|
Text { x: 15px; y: 71px; width: parent.width - 30px; text: repo.meta; font-size: 12px; color: #7b7b82; overflow: elide; }
|
||||||
|
TouchArea {
|
||||||
|
x: parent.width - 52px; y: 2px; width: 48px; height: 48px;
|
||||||
|
clicked => { root.toggle_favorite(repo.owner, repo.name); }
|
||||||
|
Text { text: repo.favorite ? "★" : "☆"; color: repo.favorite ? #f2a900 : #888890; font-size: 27px; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if 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 - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
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: #ffffff;
|
||||||
|
border-color: #dedee3;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 12px;
|
||||||
|
TouchArea { clicked => { root.select_issue(issue.number); } }
|
||||||
|
Text { x: 15px; y: 11px; width: parent.width - 44px; text: issue.title; font-size: 16px; font-weight: 600; color: #17171a; overflow: elide; }
|
||||||
|
Text { x: 15px; y: 39px; width: parent.width - 30px; height: 38px; text: issue.summary; font-size: 13px; color: #56565c; wrap: word-wrap; overflow: elide; }
|
||||||
|
if issue.labels.length > 0: HorizontalLayout {
|
||||||
|
x: 15px; y: 82px; width: parent.width - 30px; height: 22px; spacing: 5px;
|
||||||
|
for label in issue.labels: Rectangle {
|
||||||
|
width: min(104px, max(48px, (parent.width - (issue.labels.length - 1) * 5px) / issue.labels.length));
|
||||||
|
height: 22px; background: label.background; border-radius: 11px;
|
||||||
|
Text { x: 7px; width: parent.width - 14px; text: label.name; color: label.foreground; font-size: 11px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; overflow: elide; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text { x: 15px; y: issue.labels.length > 0 ? 112px : 86px; width: parent.width - 30px; text: issue.meta; font-size: 12px; color: #7b7b82; overflow: elide; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if 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 - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
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: #161619; wrap: word-wrap; }
|
||||||
|
Text { text: root.issue_meta; font-size: 13px; color: #707077; wrap: word-wrap; }
|
||||||
|
Rectangle { height: 1px; background: #d8d8dd; }
|
||||||
|
MarkdownContent { width: parent.width; blocks: root.issue_body; }
|
||||||
|
if root.comments.length > 0: Text { text: "Comments"; font-size: 17px; font-weight: 600; color: #252529; }
|
||||||
|
for comment in root.comments: CommentCard { width: parent.width; comment: comment; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
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 - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
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: #ffffff; border-color: #dedee3; 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: #727278; overflow: elide; }
|
||||||
|
Text { x: 14px; y: 31px; width: parent.width - 28px; text: pull.title; font-size: 16px; font-weight: 600; color: #17171a; overflow: elide; }
|
||||||
|
Text { x: 14px; y: 57px; width: parent.width - 28px; text: pull.summary; font-size: 13px; color: #56565c; overflow: elide; }
|
||||||
|
Text { x: 14px; y: 85px; width: parent.width - 28px; text: pull.meta; font-size: 12px; color: #7b7b82; 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 - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
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: #161619; wrap: word-wrap; }
|
||||||
|
Text { text: root.detail_meta; font-size: 13px; color: #707077; wrap: word-wrap; }
|
||||||
|
Rectangle { height: 1px; background: #d8d8dd; }
|
||||||
|
MarkdownContent { width: parent.width; blocks: root.detail_body; }
|
||||||
|
if root.comments.length > 0: Text { text: "Comments"; font-size: 17px; font-weight: 600; color: #252529; }
|
||||||
|
for comment in root.comments: CommentCard { width: parent.width; comment: comment; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
Header { x: 0; y: 0; title: root.repository_title; can_back: true; back => { root.back(); } }
|
||||||
|
if root.refreshing: Spinner { x: parent.width - 42px; y: 17px; width: 24px; height: 24px; }
|
||||||
|
if !root.loading && root.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: 70px; width: parent.width - 32px; height: parent.height - 82px;
|
||||||
|
at_top: root.repositories_at_top; refreshing: root.refreshing;
|
||||||
|
refresh => { root.refresh_commits(); }
|
||||||
|
ListView {
|
||||||
|
width: 100%; height: 100%; mouse-drag-pan-enabled: true;
|
||||||
|
scrolled => { root.repositories_at_top = self.viewport-y >= 0px; }
|
||||||
|
for commit in root.commits: Rectangle {
|
||||||
|
height: 86px; background: #ffffff; border-color: #dedee3; border-width: 1px; border-radius: 12px;
|
||||||
|
TouchArea { clicked => { root.select_commit(commit.sha); } }
|
||||||
|
Text { x: 14px; y: 11px; width: parent.width - 28px; text: commit.title; font-size: 15px; font-weight: 600; color: #17171a; overflow: elide; }
|
||||||
|
Text { x: 14px; y: 40px; width: parent.width - 28px; text: commit.meta; font-size: 12px; color: #707077; overflow: elide; }
|
||||||
|
Text { x: 14px; y: 61px; width: parent.width - 28px; text: commit.sha; font-size: 11px; color: #919198; overflow: elide; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
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: Rectangle {
|
||||||
|
height: 66px; background: #ffffff; border-color: #dedee3; border-width: 1px; border-radius: 10px;
|
||||||
|
TouchArea { clicked => { root.select_file(file.path); } }
|
||||||
|
Text { x: 13px; y: 10px; width: parent.width - 26px; text: file.path; font-size: 14px; font-weight: 600; color: #232327; overflow: elide; }
|
||||||
|
Text { x: 13px; y: 38px; width: parent.width - 26px; text: file.status; font-size: 12px; color: #74747a; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EdgeBack { x: 0; y: 0; back => { root.back(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if tab == "repositories" && page == "diff": Rectangle {
|
||||||
|
x: root.safe-area-insets.left; y: root.safe-area-insets.top;
|
||||||
|
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
||||||
|
height: root.height - root.safe-area-insets.top - root.safe-area-insets.bottom - 58px;
|
||||||
|
background: #f2f2f7;
|
||||||
|
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" ? #ddf5e3 : line.kind == "removal" ? #fde2e1 : line.kind == "hunk" ? #dcecff : line.kind == "header" ? #ececf0 : #ffffff;
|
||||||
|
Text { x: 0; width: 38px; text: line.old_number; font-family: "monospace"; font-size: 11px; color: #85858c; horizontal-alignment: right; vertical-alignment: center; }
|
||||||
|
Text { x: 42px; width: 38px; text: line.new_number; font-family: "monospace"; font-size: 11px; color: #85858c; horizontal-alignment: right; vertical-alignment: center; }
|
||||||
|
Rectangle { x: 84px; width: 1px; height: 100%; background: #d5d5da; }
|
||||||
|
Text {
|
||||||
|
x: 90px; width: parent.width - 94px; text: line.text; font-family: "monospace"; font-size: 11px;
|
||||||
|
color: line.kind == "addition" ? #176b2c : line.kind == "removal" ? #a12720 : line.kind == "hunk" ? #175b9e : #202024;
|
||||||
|
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 - 58px;
|
||||||
|
width: root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
||||||
|
height: 58px;
|
||||||
|
background: #fbfbfc;
|
||||||
|
Rectangle { width: 100%; height: 1px; background: #d4d4d8; }
|
||||||
|
TabButton {
|
||||||
|
x: 0; width: parent.width / 5; height: parent.height; icon: "⌂"; label: "Home"; active: root.tab == "home";
|
||||||
|
clicked => {
|
||||||
|
if root.tab == "home" { root.home_scroll_request += 1; }
|
||||||
|
root.select_tab("home");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TabButton {
|
||||||
|
x: parent.width / 5; width: parent.width / 5; height: parent.height; icon: "!"; label: "Issues"; active: root.tab == "issues";
|
||||||
|
clicked => {
|
||||||
|
if root.tab == "issues" {
|
||||||
|
if root.page == "servers" { root.servers_scroll_request += 1; }
|
||||||
|
if root.page == "repositories" { root.repositories_scroll_request += 1; }
|
||||||
|
if root.page == "issues" { root.issues_scroll_request += 1; }
|
||||||
|
if root.page == "issue" { root.issue_scroll_request += 1; }
|
||||||
|
}
|
||||||
|
root.select_tab("issues");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TabButton { x: parent.width * 2 / 5; width: parent.width / 5; height: parent.height; icon: "▱"; label: "Repos"; active: root.tab == "repositories"; clicked => { root.select_tab("repositories"); } }
|
||||||
|
TabButton { x: parent.width * 3 / 5; width: parent.width / 5; height: parent.height; icon: "↗"; label: "Pulls"; active: root.tab == "pulls"; clicked => { root.select_tab("pulls"); } }
|
||||||
|
TabButton { x: parent.width * 4 / 5; width: parent.width / 5; height: parent.height; icon: "≡"; label: "Settings"; active: root.tab == "settings"; clicked => { root.select_tab("settings"); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.loading && (root.tab == "issues" || root.tab == "repositories" || root.tab == "pulls"): Rectangle {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: #55ffffff;
|
||||||
|
TouchArea { }
|
||||||
|
Spinner { width: 38px; height: 38px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
|
||||||
|
}
|
||||||
|
if root.filter_open: Rectangle {
|
||||||
|
width: 100%; height: 100%; background: #66000000;
|
||||||
|
TouchArea { clicked => { root.filter_open = false; } }
|
||||||
|
Rectangle {
|
||||||
|
x: 28px; y: (parent.height - 190px) / 2; width: parent.width - 56px; height: 190px;
|
||||||
|
background: #ffffff; border-radius: 14px;
|
||||||
|
Text { x: 18px; y: 16px; width: parent.width - 36px; text: "Status"; font-size: 19px; font-weight: 650; color: #202024; }
|
||||||
|
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 = ""; } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
518
crates/app/src/view.rs
Normal file
518
crates/app/src/view.rs
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
use gotcha_gitea::models;
|
||||||
|
use slint::Color;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
activity,
|
||||||
|
domain::{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: &[models::Commit]) {
|
||||||
|
ui.set_commits(model(
|
||||||
|
commits
|
||||||
|
.iter()
|
||||||
|
.filter_map(|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(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.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() => {
|
||||||
|
("↗", format!("Created branch {branch} in {repository}"))
|
||||||
|
}
|
||||||
|
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => {
|
||||||
|
("↑", format!("Pushed to {branch} in {repository}"))
|
||||||
|
}
|
||||||
|
Some(OpType::CreateRepo) => ("▣", format!("Created {repository}")),
|
||||||
|
Some(OpType::RenameRepo) => ("✎", format!("Renamed {repository}")),
|
||||||
|
Some(OpType::StarRepo) => ("★", format!("Starred {repository}")),
|
||||||
|
Some(OpType::WatchRepo) => ("◉", format!("Started watching {repository}")),
|
||||||
|
Some(OpType::CreateIssue) => ("!", format!("Opened an issue in {repository}")),
|
||||||
|
Some(OpType::CloseIssue) => ("✓", format!("Closed an issue in {repository}")),
|
||||||
|
Some(OpType::ReopenIssue) => ("↻", format!("Reopened an issue in {repository}")),
|
||||||
|
Some(OpType::CommentIssue) => ("●", format!("Commented on an issue in {repository}")),
|
||||||
|
Some(OpType::CreatePullRequest) => ("↗", format!("Opened a pull request in {repository}")),
|
||||||
|
Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => {
|
||||||
|
("↝", format!("Merged a pull request in {repository}"))
|
||||||
|
}
|
||||||
|
Some(OpType::ClosePullRequest) => ("×", format!("Closed a pull request in {repository}")),
|
||||||
|
Some(OpType::ReopenPullRequest) => {
|
||||||
|
("↻", format!("Reopened a pull request in {repository}"))
|
||||||
|
}
|
||||||
|
Some(OpType::CommentPull) => ("●", format!("Commented on a pull request in {repository}")),
|
||||||
|
Some(OpType::ApprovePullRequest) => {
|
||||||
|
("✓", format!("Approved a pull request in {repository}"))
|
||||||
|
}
|
||||||
|
Some(OpType::RejectPullRequest) => ("×", format!("Requested changes in {repository}")),
|
||||||
|
Some(OpType::PushTag) => ("◆", format!("Pushed tag {branch} in {repository}")),
|
||||||
|
Some(OpType::DeleteTag) => ("−", format!("Deleted tag {branch} in {repository}")),
|
||||||
|
Some(OpType::DeleteBranch) => ("−", format!("Deleted branch {branch} in {repository}")),
|
||||||
|
Some(OpType::PublishRelease) => ("◆", format!("Published a release in {repository}")),
|
||||||
|
Some(_) | None => ("•", format!("Updated {repository}")),
|
||||||
|
};
|
||||||
|
let target = activity::target(activity);
|
||||||
|
let (target, owner, repository, number, sha) = match target {
|
||||||
|
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_page("pull".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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user