API browser, overview API & more

This commit is contained in:
Luke Street
2025-05-13 21:24:07 -06:00
parent 4379f97bbe
commit 9415993155
33 changed files with 1869 additions and 468 deletions
Generated
+99 -5
View File
@@ -288,7 +288,7 @@ dependencies = [
"cfg-if",
"libc",
"miniz_oxide",
"object",
"object 0.36.7 (registry+https://github.com/rust-lang/crates.io-index)",
"rustc-demangle",
"windows-targets 0.52.6",
]
@@ -772,6 +772,7 @@ dependencies = [
"decomp-dev-core",
"image",
"mime",
"num-format",
"objdiff-core",
"palette",
"resvg",
@@ -795,6 +796,7 @@ dependencies = [
"decomp-dev-images",
"image",
"itertools 0.14.0",
"lexicmp",
"libsystemd",
"maud",
"mime",
@@ -851,6 +853,12 @@ dependencies = [
"syn 2.0.100",
]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]]
name = "digest"
version = "0.10.7"
@@ -889,6 +897,15 @@ dependencies = [
"serde",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "english-to-cron"
version = "0.1.4"
@@ -982,12 +999,29 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "filetime"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
dependencies = [
"cfg-if",
"libc",
"libredox",
"windows-sys 0.59.0",
]
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flagset"
version = "0.4.6"
source = "git+https://github.com/enarx/flagset.git?rev=a1fe9369b3741e43fec45da1998e83b9d78966a2#a1fe9369b3741e43fec45da1998e83b9d78966a2"
[[package]]
name = "flate2"
version = "1.1.1"
@@ -1856,6 +1890,15 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
[[package]]
name = "lexicmp"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8f89da8fd95c4eb6274e914694bea90c7826523b26f2a2fd863d44b9d42c43"
dependencies = [
"deunicode",
]
[[package]]
name = "libc"
version = "0.2.172"
@@ -1878,6 +1921,17 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
[[package]]
name = "libredox"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags 2.9.0",
"libc",
"redox_syscall",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
@@ -2210,6 +2264,16 @@ dependencies = [
"syn 2.0.100",
]
[[package]]
name = "num-format"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3"
dependencies = [
"arrayvec",
"itoa",
]
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -2253,18 +2317,26 @@ dependencies = [
[[package]]
name = "objdiff-core"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b114e70a6ede280511c1690609afbe802f84f7aaf27c71486c0510df07e647b8"
version = "3.0.0-beta.7"
source = "git+https://github.com/encounter/objdiff?rev=0c48d711c7bd51f791b353d7d85ba948b277e2f2#0c48d711c7bd51f791b353d7d85ba948b277e2f2"
dependencies = [
"anyhow",
"object",
"encoding_rs",
"filetime",
"flagset",
"itertools 0.14.0",
"log",
"memmap2",
"num-traits",
"object 0.36.7 (git+https://github.com/gimli-rs/object?rev=a74579249e21ab8fcd3a86be588de336f18297cb)",
"pbjson",
"pbjson-build",
"prost",
"prost-build",
"serde",
"serde_json",
"similar",
"typed-path",
]
[[package]]
@@ -2276,6 +2348,14 @@ dependencies = [
"memchr",
]
[[package]]
name = "object"
version = "0.36.7"
source = "git+https://github.com/gimli-rs/object?rev=a74579249e21ab8fcd3a86be588de336f18297cb#a74579249e21ab8fcd3a86be588de336f18297cb"
dependencies = [
"memchr",
]
[[package]]
name = "octocrab"
version = "0.44.0"
@@ -3426,6 +3506,14 @@ dependencies = [
"quote",
]
[[package]]
name = "similar"
version = "2.7.0"
source = "git+https://github.com/encounter/similar.git?branch=no_std#3568aba9173fad9f66740344dbdc6bc4c1970aab"
dependencies = [
"hashbrown",
]
[[package]]
name = "simple_asn1"
version = "0.6.3"
@@ -4351,6 +4439,12 @@ dependencies = [
"core_maths",
]
[[package]]
name = "typed-path"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c462d18470a2857aa657d338af5fa67170bb48bcc80a296710ce3b0802a32566"
[[package]]
name = "typenum"
version = "1.18.0"
+2 -2
View File
@@ -21,8 +21,8 @@ hex = "0.4"
image = "0.25"
maud = { version = "0.27", features = ["axum"] }
mime = "0.3"
objdiff-core = { version = "2.5", features = ["bindings"] }
#objdiff-core = { path = "../objdiff/objdiff-core", features = ["bindings"] }
objdiff-core = { git = "https://github.com/encounter/objdiff", rev = "0c48d711c7bd51f791b353d7d85ba948b277e2f2", features = ["bindings", "serde"] }
#objdiff-core = { path = "../objdiff/objdiff-core", features = ["bindings", "serde"] }
octocrab = { version = "0.44", features = ["stream"] }
regex = "1.11"
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "zstd", "http2", "rustls-tls-native-roots"] }
Binary file not shown.
+9 -2
View File
@@ -1,7 +1,14 @@
{
"$schema": "https://biomejs.dev/schemas/1.8.0/schema.json",
"files": {
"ignore": ["node_modules", "dist", "package.json", "package-lock.json"]
"ignore": [
".sqlx",
"crates/github/graphql/schema.graphql",
"dist",
"node_modules",
"package-lock.json",
"package.json"
]
},
"organizeImports": {
"enabled": true
@@ -39,7 +46,7 @@
},
"style": {
"noNonNullAssertion": "off"
}
}
}
}
}
+13
View File
@@ -60,3 +60,16 @@ pub fn join_normalized(base: impl AsRef<Path>, path: impl AsRef<Path>) -> PathBu
out.extend(path.as_ref().components().filter(|v| matches!(v, std::path::Component::Normal(_))));
out
}
/// Format a size in bytes to a human-readable string.
/// Uses SI (kilo = 1000) units, formatted to two decimal places.
pub fn size(value: u64) -> String {
let units = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
let mut value = value as f64;
let mut unit = 0;
while value >= 1000.0 && unit < units.len() - 1 {
value /= 1000.0;
unit += 1;
}
format!("{:.2} {}", value, units[unit])
}
+5 -5
View File
@@ -3,10 +3,10 @@ query ViewerQuery($after: String) {
login
url
repositories(
first: 100,
after: $after,
ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER],
visibility: PUBLIC,
first: 100
after: $after
ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER]
visibility: PUBLIC
) {
nodes {
...repositoryFields
@@ -28,4 +28,4 @@ fragment repositoryFields on Repository {
}
name
viewerPermission
}
}
+25 -1
View File
@@ -239,6 +239,14 @@ async fn handle_workflow_run_completed(
if !project_info.project.enable_pr_comments {
return Ok(());
}
// Actions pull_request builds always merge with the latest commit on the base branch.
// We can't use the base commit from the workflow run or pull request APIs, those are
// both lies. For simplicity, we'll just always compare against the latest commit that
// we have stored.
let Some(base_commit) = project_info.commit else {
tracing::warn!("No base commit found for repository ID {}", repository_id);
return Ok(());
};
// Fetch any associated pull requests
if pull_requests.is_empty() {
let head = if let Some(head_owner) =
@@ -262,6 +270,22 @@ async fn handle_workflow_run_completed(
}
for pull_request in pull_requests {
if pull_request.head.sha != workflow_run.head_sha {
tracing::warn!(
"Pull request {} head SHA {} does not match workflow run head SHA {}",
pull_request.id,
pull_request.head.sha,
workflow_run.head_sha
);
continue;
}
if repository.default_branch.as_ref().is_none_or(|b| pull_request.base.ref_field != *b)
{
tracing::warn!(
"Pull request {} base branch {} does not match default branch {}",
pull_request.id,
pull_request.base.ref_field,
repository.default_branch.as_deref().unwrap_or("[unknown]")
);
continue;
}
tracing::info!("Processing pull request {}", pull_request.id);
@@ -274,7 +298,7 @@ async fn handle_workflow_run_completed(
.get_report(
&project_info.project.owner,
&project_info.project.repo,
&pull_request.base.sha,
&base_commit.sha,
&artifact.version,
)
.await?
+10 -8
View File
@@ -6,15 +6,17 @@ publish = false
[dependencies]
anyhow.workspace = true
resvg = "0.45"
tracing.workspace = true
objdiff-core.workspace = true
image.workspace = true
serde.workspace = true
badge-maker = "0.3"
axum.workspace = true
badge-maker = "0.3"
decomp-dev-core = { path = "../core" }
tokio.workspace = true
image.workspace = true
mime.workspace = true
streemap = "0.1"
num-format = "0.4"
objdiff-core.workspace = true
palette = "0.7"
resvg = "0.45"
serde.workspace = true
streemap = "0.1"
tokio.workspace = true
tracing.workspace = true
+56 -7
View File
@@ -1,4 +1,5 @@
use anyhow::{Result, anyhow};
use decomp_dev_core::util::size;
use image::ImageFormat;
use objdiff_core::bindings::report::Measures;
use serde::{Deserialize, Serialize};
@@ -29,6 +30,26 @@ pub struct ShieldResponse {
label_color: Option<String>,
}
fn format_percent(value: f32) -> String { format!("{:.2}%", value) }
fn format_bytes(a: u64, b: u64) -> String {
let mut a_buf = num_format::Buffer::default();
let mut b_buf = num_format::Buffer::default();
a_buf.write_formatted(&a, &num_format::Locale::en);
b_buf.write_formatted(&b, &num_format::Locale::en);
format!("{} B / {} B", a_buf, b_buf)
}
fn format_num(a: u32, b: u32) -> String {
let mut a_buf = num_format::Buffer::default();
let mut b_buf = num_format::Buffer::default();
a_buf.write_formatted(&a, &num_format::Locale::en);
b_buf.write_formatted(&b, &num_format::Locale::en);
format!("{} / {}", a_buf, b_buf)
}
fn format_size(a: u64, b: u64) -> String { format!("{} / {}", size(a), size(b)) }
pub fn render(
measures: &Measures,
default_label: &str,
@@ -37,16 +58,44 @@ pub fn render(
let label = params.label.clone().unwrap_or_else(|| default_label.to_string());
let message = if let Some(measure) = &params.measure {
match measure.as_str() {
"code" => format!("{:.2}%", measures.matched_code_percent),
"data" => format!("{:.2}%", measures.matched_data_percent),
"functions" => format!("{}/{}", measures.matched_functions, measures.total_functions),
"complete_code" => format!("{:.2}%", measures.complete_code_percent),
"complete_data" => format!("{:.2}%", measures.complete_data_percent),
"complete_units" => format!("{}/{}", measures.complete_units, measures.total_units),
"fuzzy_match_percent" | "fuzzy_match" => format_percent(measures.fuzzy_match_percent),
"matched_code_percent" | "matched_code" | "code" => {
format_percent(measures.matched_code_percent)
}
"matched_code_bytes" => format_bytes(measures.matched_code, measures.total_code),
"matched_code_size" => format_size(measures.matched_code, measures.total_code),
"matched_data_percent" | "matched_data" | "data" => {
format_percent(measures.matched_data_percent)
}
"matched_data_bytes" => format_bytes(measures.matched_data, measures.total_data),
"matched_data_size" => format_size(measures.matched_data, measures.total_data),
"matched_functions" | "functions" => {
format_num(measures.matched_functions, measures.total_functions)
}
"matched_functions_percent" => format_percent(measures.matched_functions_percent),
"complete_code_percent" | "complete_code" => {
format_percent(measures.complete_code_percent)
}
"complete_code_bytes" => format_bytes(measures.complete_code, measures.total_code),
"complete_code_size" => format_size(measures.complete_code, measures.total_code),
"complete_data_percent" | "complete_data" => {
format_percent(measures.complete_data_percent)
}
"complete_data_bytes" => format_bytes(measures.complete_data, measures.total_data),
"complete_data_size" => format_size(measures.complete_data, measures.total_data),
"complete_units" => format_num(measures.complete_units, measures.total_units),
"complete_units_percent" => {
let percent = if measures.total_units == 0 {
100.0
} else {
measures.complete_units as f32 / measures.total_units as f32 * 100.0
};
format_percent(percent)
}
_ => return Err(anyhow!("Unknown measure")),
}
} else {
format!("{:.2}%", measures.matched_code_percent)
format_percent(measures.matched_code_percent)
};
Ok(ShieldResponse {
schema_version: 1,
+1
View File
@@ -13,6 +13,7 @@ decomp-dev-db = { path = "../db" }
decomp-dev-github = { path = "../github" }
decomp-dev-images = { path = "../images" }
itertools = "0.14"
lexicmp = "0.2"
maud.workspace = true
mime.workspace = true
objdiff-core.workspace = true
+45
View File
@@ -0,0 +1,45 @@
use axum::response::{IntoResponse, Response};
use decomp_dev_auth::CurrentUser;
use decomp_dev_core::AppError;
use maud::{DOCTYPE, html};
use crate::handlers::common::{Load, TemplateContext, nav_links};
pub async fn overview(
mut ctx: TemplateContext,
current_user: Option<CurrentUser>,
) -> Result<Response, AppError> {
let rendered = html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
title { "API • decomp.dev" }
(ctx.header().await)
(ctx.chunks("main", Load::Deferred).await)
(ctx.chunks("api", Load::Deferred).await)
}
body {
header {
nav {
ul {
li {
a href="/" { strong { "decomp.dev" } }
}
li {
a href="/api" { "API" }
}
}
(nav_links())
}
}
main {
h1 { "API" }
#root {}
}
}
(ctx.footer(current_user.as_ref()))
}
};
Ok((ctx, rendered).into_response())
}
+36 -26
View File
@@ -13,7 +13,7 @@ use axum::{
response::{IntoResponseParts, ResponseParts},
};
use decomp_dev_auth::CurrentUser;
use decomp_dev_core::AppError;
use decomp_dev_core::{AppError, util::size};
use maud::{Markup, PreEscaped, Render, html};
use objdiff_core::bindings::report::Measures;
use regex::Regex;
@@ -120,12 +120,11 @@ impl TemplateContext {
meta name="apple-mobile-web-app-title" content="decomp.dev";
meta name="theme-color" content="#181c25" media="(prefers-color-scheme: dark)";
meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)";
(self.chunks("entry", Load::Blocking).await)
link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96";
link rel="icon" type="image/png" href="/favicon.png" sizes="96x96";
link rel="icon" type="image/svg+xml" href="/favicon.svg";
link rel="shortcut icon" href="/favicon.ico";
link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180";
link rel="manifest" href="/site.webmanifest";
(self.chunks("entry", Load::Blocking).await)
}
}
@@ -163,7 +162,7 @@ impl TemplateContext {
Style,
Font,
}
let mut push = |kind: ResourceKind, path: &str, load: Load| {
let mut push = async |kind: ResourceKind, path: &str, load: Load| {
match load {
Load::Preload => {
if self.added_resources.contains_key(path) {
@@ -202,14 +201,34 @@ impl TemplateContext {
ResourceKind::Script | ResourceKind::Style => self.nonce.as_deref(),
_ => None,
};
let can_inline = path.starts_with('/')
&& matches!(kind, ResourceKind::Script | ResourceKind::Style)
&& tokio::fs::metadata(format!("dist{path}"))
.await
.is_ok_and(|m| m.is_file() && m.len() <= 2048);
let rendered = match load {
Load::Preload => html! {
Load::Preload if !can_inline => html! {
link rel="preload" href=(path) as=(match kind {
ResourceKind::Script => "script",
ResourceKind::Style => "style",
ResourceKind::Font => "font",
}) crossorigin[crossorigin] nonce=[nonce];
},
Load::Preload => Markup::default(),
Load::Blocking | Load::Deferred if can_inline => {
let content =
tokio::fs::read_to_string(format!("dist{path}")).await.unwrap_or_default();
let content = escape_script(&content);
match kind {
ResourceKind::Script => html! {
script type=[(load == Load::Deferred).then_some("module")] nonce=[nonce] { (content) }
},
ResourceKind::Style => html! {
style nonce=[nonce] { (content) }
},
ResourceKind::Font => Markup::default(),
}
}
Load::Blocking | Load::Deferred => match kind {
ResourceKind::Script => html! {
script src=(path) defer[load == Load::Deferred] crossorigin[crossorigin] nonce=[nonce] {};
@@ -223,14 +242,14 @@ impl TemplateContext {
out.push_str(&rendered.0);
};
for path in &entry.initial.js {
push(ResourceKind::Script, path, load);
push(ResourceKind::Script, path, load).await;
}
for path in &entry.initial.css {
push(ResourceKind::Style, path, load);
push(ResourceKind::Style, path, load).await;
}
for path in &entry.assets {
if path.ends_with(".woff2") {
push(ResourceKind::Font, path, Load::Preload);
push(ResourceKind::Font, path, Load::Preload).await;
}
}
PreEscaped(out)
@@ -268,6 +287,10 @@ impl TemplateContext {
}
}
}
// Analytics
script src="https://umami.decomp.dev/script.js" defer crossorigin
nonce=[self.nonce.as_deref()]
data-website-id="80d6109f-52cc-42a9-98c9-9820dc8c4435" {};
}
}
@@ -378,7 +401,7 @@ impl Render for ProgressSections {
};
out.0.push_str(&rendered.0);
}
if self.rendered.0.is_empty() {
if self.kind.is_empty() {
return out;
}
html! {
@@ -404,19 +427,6 @@ pub fn nav_links() -> Markup {
}
}
/// Format a size in bytes to a human-readable string.
/// Uses SI (kilo = 1000) units, formatted to two decimal places.
pub fn size(value: u64) -> String {
let units = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
let mut value = value as f64;
let mut unit = 0;
while value >= 1000.0 && unit < units.len() - 1 {
value /= 1000.0;
unit += 1;
}
format!("{:.2} {}", value, units[unit])
}
pub async fn get_robots() -> Result<String, AppError> {
static ROBOTS_CACHE: LazyLock<std::sync::RwLock<Option<String>>> =
LazyLock::new(|| std::sync::RwLock::new(None));
@@ -445,7 +455,7 @@ pub async fn get_robots() -> Result<String, AppError> {
}
pub fn escape_script(text: &str) -> PreEscaped<Cow<str>> {
static REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new("<(?:!--|/?script)").unwrap());
static REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new("<(?i:!--|/?script)").unwrap());
PreEscaped(REGEX.replace_all(text, |caps: &regex::Captures| format!("\\x3C{}", &caps[0][1..])))
}
@@ -455,8 +465,8 @@ mod test {
#[test]
fn test_escape_script() {
let input = r#"<!--<script>alert('test');</script>-->"#;
let expected = "\\x3C!--\\x3Cscript>alert('test');\\x3C/script>-->";
let input = r#"<!--<SCRIPT>alert('test');</ScRiPt>-->"#;
let expected = "\\x3C!--\\x3CSCRIPT>alert('test');\\x3C/ScRiPt>-->";
assert_eq!(escape_script(input).0.as_ref(), expected);
}
}
+1 -1
View File
@@ -77,7 +77,7 @@ pub async fn csp_middleware(mut req: Request, next: Next) -> Response {
header.push(' ');
header.push_str(domain);
}
header.push_str(";connect-src 'self'");
header.push_str(";connect-src 'self' https://umami.decomp.dev");
for domain in &extra_domains {
header.push(' ');
header.push_str(domain);
+5 -4
View File
@@ -31,7 +31,7 @@ pub async fn manage(
.await?
.into_iter()
.filter(|p| current_user.can_manage_repo(p.project.id))
.sorted_by(|a, b| a.project.name().cmp(&b.project.name()))
.sorted_by(|a, b| lexicmp::natural_lexical_cmp(&a.project.name(), &b.project.name()))
.collect::<Vec<_>>();
let rendered = html! {
@@ -49,7 +49,7 @@ pub async fn manage(
nav {
ul {
li {
a href="https://decomp.dev" { strong { "decomp.dev" } }
a href="/" { strong { "decomp.dev" } }
}
li {
a href="/manage" { "Manage" }
@@ -116,6 +116,7 @@ async fn render_new(
.map(|r| {
(r.id, format!("{}/{}", r.owner, r.name), projects.iter().any(|p| p.project.id == r.id))
})
.sorted_by(|a, b| lexicmp::lexical_cmp(&a.1, &b.1))
.collect::<Vec<_>>();
let current_name = prefill.as_ref().and_then(|p| p.name.as_deref()).unwrap_or("");
@@ -149,7 +150,7 @@ async fn render_new(
nav {
ul {
li {
a href="https://decomp.dev" { strong { "decomp.dev" } }
a href="/" { strong { "decomp.dev" } }
}
li {
a href="/manage" { "Manage" }
@@ -366,7 +367,7 @@ async fn render_manage_project(
nav {
ul {
li {
a href="https://decomp.dev" { strong { "decomp.dev" } }
a href="/" { strong { "decomp.dev" } }
}
li {
a href="/manage" { "Manage" }
+31 -5
View File
@@ -3,7 +3,8 @@ use std::str::FromStr;
use axum::{
Router,
extract::Request,
http::{HeaderMap, HeaderValue, header},
http::{HeaderMap, HeaderValue, header, header::Entry},
response::Response,
routing::{get, post},
};
use decomp_dev_images::image_mime_from_ext;
@@ -13,6 +14,7 @@ use tower_http::services::ServeDir;
use crate::AppState;
mod api;
mod auth;
mod common;
pub mod csp;
@@ -28,9 +30,10 @@ pub fn build_router() -> Router<AppState> {
<ServeDir as ServiceExt<Request>>::map_response(
ServeDir::new("dist/static"),
|mut response| {
add_charset(&mut response);
// Cache static (hashed) files for a year, mark immutable
response.headers_mut().insert(
header::CACHE_CONTROL,
add_cache_control(
&mut response,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
response
@@ -40,15 +43,17 @@ pub fn build_router() -> Router<AppState> {
.fallback_service(<ServeDir as ServiceExt<Request>>::map_response(
ServeDir::new("dist"),
|mut response| {
add_charset(&mut response);
// Cache non-hashed public files for a day, mark must-revalidate
response.headers_mut().insert(
header::CACHE_CONTROL,
add_cache_control(
&mut response,
HeaderValue::from_static("public, max-age=86400, must-revalidate"),
);
response
},
))
.route("/robots.txt", get(common::get_robots))
.route("/api", get(api::overview))
.route("/api/github/webhook", post(decomp_dev_github::webhook::webhook))
.route("/api/github/oauth", get(decomp_dev_auth::oauth))
.route("/login", get(auth::login))
@@ -61,11 +66,32 @@ pub fn build_router() -> Router<AppState> {
.route("/manage/{owner}/{repo}/refresh", post(manage::manage_project_refresh))
.route("/og.png", get(decomp_dev_images::get_og))
.route("/", get(project::get_projects))
.route("/projects", get(project::get_projects))
.route("/projects.json", get(project::get_projects))
.route("/projects/{id}", get(report::get_report))
.route("/{owner}/{repo}", get(report::get_report))
.route("/{owner}/{repo}/{version}", get(report::get_report))
.route("/{owner}/{repo}/{version}/{commit}", get(report::get_report))
}
/// Adds a charset to the Content-Type header if it is missing and the type is text/*.
fn add_charset<ResBody>(response: &mut Response<ResBody>) {
let Entry::Occupied(mut existing) = response.headers_mut().entry(header::CONTENT_TYPE) else {
return;
};
let Some(mime) = existing.get().to_str().ok().and_then(|v| Mime::from_str(v).ok()) else {
return;
};
if mime.type_() == mime::TEXT && mime.get_param("charset").is_none() {
existing.insert(format!("{};charset=utf-8", mime).parse().unwrap());
}
}
/// Adds a Cache-Control header if it is missing.
fn add_cache_control<ResBody>(response: &mut Response<ResBody>, value: HeaderValue) {
response.headers_mut().entry(header::CACHE_CONTROL).or_insert(value);
}
pub fn parse_accept(headers: &HeaderMap, ext: Option<&str>) -> Vec<Mime> {
// Explicit extension takes precedence
if let Some(ext) = ext {
+179 -44
View File
@@ -2,31 +2,37 @@ use std::{str::FromStr, sync::Arc};
use anyhow::{Context, anyhow};
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
};
use decomp_dev_auth::CurrentUser;
use decomp_dev_core::{
AppError, FullUri,
models::{Commit, Platform, Project},
util::UrlExt,
models::{CachedReportFile, Commit, Platform, ProjectInfo, ReportInner},
util::{UrlExt, size},
};
use maud::{DOCTYPE, Markup, html};
use objdiff_core::bindings::report::Measures;
use serde::{Deserialize, Serialize};
use time::{UtcDateTime, format_description::well_known::Rfc3339};
use tokio::{sync::Semaphore, task::JoinSet};
use url::Url;
use crate::{
AppState,
handlers::common::{Load, ProgressSections, TemplateContext, date, nav_links, size, timeago},
handlers::{
common::{Load, ProgressSections, TemplateContext, date, nav_links, timeago},
parse_accept,
report::TemplateMeasures,
},
};
struct ProjectInfoContext {
project: Project,
commit: Commit,
info: ProjectInfo,
measures: Measures,
report: Option<CachedReportFile>,
code_progress: ProgressSections,
}
@@ -56,24 +62,113 @@ pub struct ProgressSection {
pub tooltip: String,
}
#[derive(serde::Serialize)]
pub struct ProjectsResponse {
pub projects: Vec<ProjectResponse>,
}
#[derive(serde::Serialize)]
pub struct ProjectResponse {
pub id: u64,
pub owner: String,
pub repo: String,
pub repo_url: String,
pub name: Option<String>,
pub short_name: Option<String>,
pub platform: Option<String>,
pub default_version: Option<String>,
pub default_category: Option<String>,
pub commit: Option<CommitResponse>,
pub measures: TemplateMeasures,
pub report_versions: Vec<String>,
pub report_categories: Vec<CategoryResponse>,
}
impl ProjectResponse {
pub fn new<U>(info: &ProjectInfo, measures: &Measures, report: &ReportInner<U>) -> Self {
ProjectResponse {
id: info.project.id,
owner: info.project.owner.clone(),
repo: info.project.repo.clone(),
repo_url: info.project.repo_url(),
name: info.project.name.clone(),
short_name: info.project.short_name.clone(),
platform: info.project.platform.clone(),
default_version: info.default_version().map(|v| v.to_string()),
default_category: info.project.default_category.clone(),
commit: info.commit.clone().map(CommitResponse::from),
measures: measures.into(),
report_versions: info.report_versions.clone(),
report_categories: report
.categories
.iter()
.map(|c| CategoryResponse {
id: c.id.clone(),
name: c.name.clone(),
measures: c.measures.as_ref().map(TemplateMeasures::from).unwrap_or_default(),
})
.collect(),
}
}
}
#[derive(serde::Serialize)]
pub struct CategoryResponse {
pub id: String,
pub name: String,
pub measures: TemplateMeasures,
}
#[derive(serde::Serialize)]
pub struct CommitResponse {
pub sha: String,
pub message: Option<String>,
pub timestamp: String,
}
impl From<Commit> for CommitResponse {
fn from(value: Commit) -> Self {
Self {
sha: value.sha,
message: value.message,
timestamp: value.timestamp.format(&Rfc3339).unwrap_or_else(|_| "[invalid]".to_string()),
}
}
}
fn extract_extension(uri: &Uri) -> Option<String> {
let path = uri.path();
if let Some(pos) = path.rfind('.') {
let ext = &path[pos + 1..];
if !ext.is_empty() {
return Some(ext.to_string());
}
}
None
}
pub async fn get_projects(
mut ctx: TemplateContext,
ctx: TemplateContext,
State(state): State<AppState>,
Query(query): Query<ProjectsQuery>,
FullUri(uri): FullUri,
current_user: Option<CurrentUser>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let ext = extract_extension(&uri);
let acceptable = parse_accept(&headers, ext.as_deref());
if acceptable.is_empty() {
return Err(AppError::Status(StatusCode::NOT_ACCEPTABLE));
}
let projects = state.db.get_projects().await?;
let mut out = projects
.iter()
.filter_map(|p| {
let commit = p.commit.as_ref()?;
Some(ProjectInfoContext {
project: p.project.clone(),
commit: commit.clone(),
measures: Default::default(),
code_progress: Default::default(),
})
.map(|p| ProjectInfoContext {
info: p.clone(),
measures: Default::default(),
report: None,
code_progress: Default::default(),
})
.collect::<Vec<_>>();
@@ -105,8 +200,9 @@ pub async fn get_projects(
while let Some(result) = join_set.join_next().await {
match result {
Ok((info, Ok(Some(file)))) => {
if let Some(c) = out.iter_mut().find(|i| i.project.id == info.project.id) {
if let Some(c) = out.iter_mut().find(|i| i.info.project.id == info.project.id) {
c.measures = *file.report.measures(info.project.default_category.as_deref());
c.report = Some(file.clone());
c.code_progress = ctx.code_progress_sections(&c.measures);
}
}
@@ -122,11 +218,6 @@ pub async fn get_projects(
}
}
let mut combined_styles = ProgressSections { nonce: ctx.nonce.clone(), ..Default::default() };
for info in &mut out {
combined_styles.width_classes.append(&mut info.code_progress.width_classes);
}
let current_sort_key = query.sort.as_deref().unwrap_or("updated");
let current_sort = SORT_OPTIONS
.iter()
@@ -134,8 +225,16 @@ pub async fn get_projects(
.copied()
.ok_or(AppError::Status(StatusCode::BAD_REQUEST))?;
match current_sort.key {
"name" => out.sort_by(|a, b| a.project.name().cmp(&b.project.name())),
"updated" => out.sort_by(|a, b| b.commit.timestamp.cmp(&a.commit.timestamp)),
"name" => out.sort_by(|a, b| {
lexicmp::natural_lexical_cmp(&a.info.project.name(), &b.info.project.name())
}),
"updated" => out.sort_by(|a, b| {
let a_ts =
a.report.as_ref().map(|r| r.commit.timestamp).unwrap_or(UtcDateTime::UNIX_EPOCH);
let b_ts =
b.report.as_ref().map(|r| r.commit.timestamp).unwrap_or(UtcDateTime::UNIX_EPOCH);
b_ts.cmp(&a_ts)
}),
"matched_code_percent" => out.sort_by(|a, b| {
b.measures
.matched_code_percent
@@ -157,8 +256,42 @@ pub async fn get_projects(
_ => return Err(AppError::Status(StatusCode::BAD_REQUEST)),
}
for mime in acceptable {
if (mime.type_() == mime::STAR && mime.subtype() == mime::STAR)
|| (mime.type_() == mime::TEXT && mime.subtype() == mime::HTML)
{
return render_project(ctx, out, uri, current_sort, current_user.as_ref()).await;
} else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON {
let projects = out
.into_iter()
.filter_map(|info| {
Some(ProjectResponse::new(
&info.info,
&info.measures,
&info.report.as_ref()?.report,
))
})
.collect::<Vec<_>>();
return Ok(Json(ProjectsResponse { projects }).into_response());
}
}
Err(AppError::Status(StatusCode::NOT_ACCEPTABLE))
}
async fn render_project(
mut ctx: TemplateContext,
mut out: Vec<ProjectInfoContext>,
uri: Uri,
current_sort: SortOption,
current_user: Option<&CurrentUser>,
) -> Result<Response, AppError> {
let mut combined_styles = ProgressSections { nonce: ctx.nonce.clone(), ..Default::default() };
for info in &mut out {
combined_styles.width_classes.append(&mut info.code_progress.width_classes);
}
let request_url = Url::parse(&uri.to_string()).context("Failed to parse URI")?;
let canonical_url = request_url.with_path("");
let canonical_url = request_url.with_path("/projects");
let rendered = html! {
(DOCTYPE)
@@ -168,6 +301,7 @@ pub async fn get_projects(
title { "Projects • decomp.dev" }
(ctx.header().await)
(ctx.chunks("main", Load::Deferred).await)
link rel="canonical" href=(canonical_url);
meta name="description" content="Decompilation progress reports";
meta property="og:title" content="Decompilation progress reports";
meta property="og:description" content="Progress reports for matching decompilation projects";
@@ -179,10 +313,10 @@ pub async fn get_projects(
nav {
ul {
li {
a href="https://decomp.dev" { strong { "decomp.dev" } }
a href="/" { strong { "decomp.dev" } }
}
li {
a href="/" { "Projects" }
a href="/projects" { "Projects" }
}
li class="md" {
details class="dropdown" {
@@ -231,7 +365,7 @@ pub async fn get_projects(
(project_fragment(project, current_sort, &canonical_url))
}
}
(ctx.footer(current_user.as_ref()))
(ctx.footer(current_user))
}
}
};
@@ -239,48 +373,49 @@ pub async fn get_projects(
}
fn project_fragment(
info: ProjectInfoContext,
ctx: ProjectInfoContext,
current_sort: SortOption,
canonical_url: &Url,
) -> Markup {
let project_path =
canonical_url.with_path(&format!("/{}/{}", info.project.owner, info.project.repo));
let commit_url = format!(
"https://github.com/{}/{}/commit/{}",
info.project.owner, info.project.repo, info.commit.sha
);
let project = &ctx.info.project;
let Some(commit) = ctx.report.as_ref().map(|r| r.commit.clone()) else {
return Markup::default();
};
let project_path = canonical_url.with_path(&format!("/{}/{}", project.owner, project.repo));
let commit_url =
format!("https://github.com/{}/{}/commit/{}", project.owner, project.repo, commit.sha);
html! {
article class="project" {
div class="project-header" {
h3 class="project-title" {
a href=(project_path) { (info.project.name()) }
a href=(project_path) { (project.name()) }
}
@if let Some(platform) = &info.project.platform {
@if let Some(platform) = &project.platform {
@let platform_name = Platform::from_str(platform).map(|p| p.name()).unwrap_or(platform);
span class=(format!("platform-icon icon-{platform}")) title=(platform_name) {}
}
}
h6 {
@if current_sort.key == "total_code" || current_sort.key == "matched_code" {
(size(info.measures.matched_code))
(size(ctx.measures.matched_code))
" matched code | "
(size(info.measures.total_code))
(size(ctx.measures.total_code))
" total code"
} @else {
(format!("{:.2}%", info.measures.matched_code_percent))
(format!("{:.2}%", ctx.measures.matched_code_percent))
" decompiled"
@if info.measures.complete_code_percent > 0.0 {
@if ctx.measures.complete_code_percent > 0.0 {
" | "
(format!("{:.2}%", info.measures.complete_code_percent))
(format!("{:.2}%", ctx.measures.complete_code_percent))
" fully linked"
}
}
}
(info.code_progress)
(ctx.code_progress)
small class="muted" {
span title=(date(info.commit.timestamp)) { "Updated " (timeago(info.commit.timestamp)) }
span title=(date(commit.timestamp)) { "Updated " (timeago(commit.timestamp)) }
" in commit "
a href=(commit_url) target="_blank" { (info.commit.sha[..7]) }
a href=(commit_url) target="_blank" { (commit.sha[..7]) }
}
}
}
+108 -24
View File
@@ -11,7 +11,7 @@ use decomp_dev_auth::CurrentUser;
use decomp_dev_core::{
AppError, FullUri,
models::{FullReportFile, ProjectInfo},
util::UrlExt,
util::{UrlExt, size},
};
use decomp_dev_images::{
badge,
@@ -28,14 +28,18 @@ use url::Url;
use super::{parse_accept, treemap};
use crate::{
AppState,
handlers::common::{Load, TemplateContext, escape_script, nav_links, size},
handlers::{
common::{Load, TemplateContext, escape_script, nav_links},
project::ProjectResponse,
},
proto::{PROTOBUF, Protobuf},
};
#[derive(Deserialize)]
pub struct ReportParams {
owner: String,
repo: String,
id: Option<String>,
owner: Option<String>,
repo: Option<String>,
version: Option<String>,
commit: Option<String>,
}
@@ -47,7 +51,9 @@ const DEFAULT_IMAGE_HEIGHT: u32 = 475;
#[serde(rename_all = "camelCase")]
pub struct ReportQuery {
mode: Option<String>,
version: Option<String>,
category: Option<String>,
commit: Option<String>,
w: Option<u32>,
h: Option<u32>,
#[serde(flatten)]
@@ -173,9 +179,23 @@ fn extract_extension(params: ReportParams) -> (ReportParams, Option<String>) {
);
}
}
} else if let Some((repo, ext)) = params.repo.rsplit_once('.') {
if is_valid_extension(ext) {
return (ReportParams { repo: repo.to_string(), ..params }, Some(ext.to_string()));
} else if let Some(repo) = params.repo.as_deref() {
if let Some((repo, ext)) = repo.rsplit_once('.') {
if is_valid_extension(ext) {
return (
ReportParams { repo: Some(repo.to_string()), ..params },
Some(ext.to_string()),
);
}
}
} else if let Some(id) = params.id.as_deref() {
if let Some((id, ext)) = id.rsplit_once('.') {
if is_valid_extension(ext) {
return (
ReportParams { id: Some(id.to_string()), ..params },
Some(ext.to_string()),
);
}
}
}
(params, None)
@@ -196,45 +216,95 @@ pub async fn get_report(
return Err(AppError::Status(StatusCode::NOT_ACCEPTABLE));
}
let mut commit = params.commit.as_deref();
let mut commit = query.commit.as_deref().or(params.commit.as_deref());
if matches!(commit, Some(c) if c.eq_ignore_ascii_case("latest")) {
commit = None;
}
let Some(project_info) = state.db.get_project_info(&params.owner, &params.repo, commit).await?
else {
let Some(project_info) = (match (&params.id, &params.owner, &params.repo) {
(Some(id), _, _) => {
let id: u64 = id.parse().map_err(|_| AppError::Status(StatusCode::BAD_REQUEST))?;
state.db.get_project_info_by_id(id, commit).await?
}
(_, Some(owner), Some(repo)) => state.db.get_project_info(owner, repo, commit).await?,
_ => return Err(AppError::Status(StatusCode::BAD_REQUEST)),
}) else {
return Err(AppError::Status(StatusCode::NOT_FOUND));
};
let Some(commit) = project_info.commit.as_ref() else {
return Err(AppError::Status(StatusCode::NOT_FOUND));
};
let version = if let Some(version) = &params.version {
let version = if let Some(version) = query.version.as_deref().or(params.version.as_deref()) {
if version.eq_ignore_ascii_case("default") {
project_info.default_version().ok_or(AppError::Status(StatusCode::NOT_FOUND))?
} else {
version.as_str()
version
}
} else if let Some(default_version) = project_info.default_version() {
default_version
} else {
return Err(AppError::Status(StatusCode::NOT_FOUND));
};
let Some(report) =
state.db.get_report(&params.owner, &params.repo, &commit.sha, version).await?
let Some(report) = state
.db
.get_report(&project_info.project.owner, &project_info.project.repo, &commit.sha, version)
.await?
else {
return Err(AppError::Status(StatusCode::NOT_FOUND));
};
let report = state.db.upgrade_report(&report).await?;
let scope = apply_scope(&report, &project_info, &query)?;
match query.mode.as_deref().unwrap_or("report").to_ascii_lowercase().as_str() {
"shield" => mode_shield(&scope, query, &acceptable),
"report" => mode_report(&scope, &state, uri, query, ctx, &acceptable, current_user).await,
"measures" => mode_measures(&scope, &acceptable),
match query.mode.as_deref().unwrap_or("overview").to_ascii_lowercase().as_str() {
"history" => mode_history(&scope, &state, uri, query, ctx, &acceptable, current_user).await,
"measures" => mode_measures(&scope, &acceptable),
"overview" => {
mode_overview(&scope, &state, uri, query, ctx, &acceptable, current_user).await
}
"report" => mode_report(&scope, &state, uri, query, ctx, &acceptable, current_user).await,
"shield" => mode_shield(&scope, query, &acceptable),
_ => Err(AppError::Status(StatusCode::BAD_REQUEST)),
}
}
#[allow(clippy::too_many_arguments)]
async fn mode_overview(
scope: &Scope<'_>,
state: &AppState,
uri: Uri,
query: ReportQuery,
ctx: TemplateContext,
acceptable: &[Mime],
current_user: Option<CurrentUser>,
) -> Result<Response, AppError> {
for mime in acceptable {
if (mime.type_() == mime::STAR && mime.subtype() == mime::STAR)
|| (mime.type_() == mime::TEXT && mime.subtype() == mime::HTML)
{
return render_report(scope, state, uri, current_user, ctx).await;
} else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON {
let result =
ProjectResponse::new(scope.project_info, scope.measures, &scope.report.report);
return Ok(Json(result).into_response());
} else if mime.type_() == mime::IMAGE && mime.subtype() == mime::SVG {
let (w, h) = query.size();
let svg = treemap::render_svg(&scope.units, w, h);
return Ok(([(header::CONTENT_TYPE, mime::IMAGE_SVG.as_ref())], svg).into_response());
} else if mime.type_() == mime::IMAGE {
let format = if mime.subtype() == mime::STAR {
// Default to PNG
ImageFormat::Png
} else {
ImageFormat::from_mime_type(mime.essence_str())
.ok_or_else(|| AppError::Status(StatusCode::NOT_ACCEPTABLE))?
};
let (w, h) = query.size();
let data = treemap::render_image(&scope.units, w, h, format)?;
return Ok(([(header::CONTENT_TYPE, format.to_mime_type())], data).into_response());
}
}
Err(AppError::Status(StatusCode::NOT_ACCEPTABLE))
}
#[allow(clippy::too_many_arguments)]
async fn mode_report(
scope: &Scope<'_>,
@@ -327,6 +397,7 @@ fn mode_measures(
struct ReportHistoryEntry {
timestamp: String,
commit_sha: String,
commit_message: Option<String>,
measures: TemplateMeasures,
}
@@ -373,6 +444,7 @@ async fn mode_history(
.format(&Rfc3339)
.unwrap_or_else(|_| "[invalid]".to_string()),
commit_sha: report.commit.sha,
commit_message: report.commit.message,
measures: TemplateMeasures::from(measures),
});
}
@@ -576,7 +648,9 @@ async fn render_report(
"/{}/{}/{}/{}",
project_info.project.owner, project_info.project.repo, report.version, report.commit.sha
));
let image_url = canonical_url.with_path(&format!("{}.png", canonical_url.path()));
let image_url = canonical_url
.with_path(&format!("{}.png", canonical_url.path()))
.query_param("mode", Some("report"));
let versions = project_info
.report_versions
@@ -671,6 +745,7 @@ async fn render_report(
(header)
(ctx.chunks("main", Load::Deferred).await)
(ctx.chunks("report", Load::Preload).await)
link rel="canonical" href=(canonical_url);
meta name="description" content=(format!("Decompilation progress report for {project_name}"));
meta property="og:title" content=(format!("{project_short_name_with_label} is {:.2}% decompiled", measures.matched_code_percent));
meta property="og:description" content=(format!("Decompilation progress report for {project_name}"));
@@ -682,10 +757,10 @@ async fn render_report(
nav {
ul {
li {
a href="https://decomp.dev" { strong { "decomp.dev" } }
a href="/" { strong { "decomp.dev" } }
}
li {
a href="/" { "Projects" }
a href="/projects" { "Projects" }
}
li {
a href=(project_base_path) { (project_short_name) }
@@ -711,6 +786,12 @@ async fn render_report(
details class="dropdown" {
summary {}
ul dir="rtl" {
li {
a href=(format!("/api?project={}", project_info.project.id)) {
"API "
span .icon-code { " " }
}
}
li {
a href=(project_history_path) {
"History "
@@ -865,7 +946,9 @@ async fn render_history(
"/{}/{}/{}",
project_info.project.owner, project_info.project.repo, report.version
));
let image_url = canonical_url.with_path(&format!("{}.png", canonical_url.path()));
let image_url = canonical_url
.with_path(&format!("{}.png", canonical_url.path()))
.query_param("mode", Some("report"));
let versions = project_info
.report_versions
@@ -929,6 +1012,7 @@ async fn render_history(
(header)
(ctx.chunks("main", Load::Deferred).await)
(ctx.chunks("history", Load::Preload).await)
link rel="canonical" href=(canonical_url);
meta name="description" content=(format!("Decompilation progress history for {project_name}"));
meta property="og:title" content=(format!("{project_short_name_with_label} is {:.2}% decompiled", measures.matched_code_percent));
meta property="og:description" content=(format!("Decompilation progress history for {project_name}"));
@@ -940,10 +1024,10 @@ async fn render_history(
nav {
ul {
li {
a href="https://decomp.dev" { strong { "decomp.dev" } }
a href="/" { strong { "decomp.dev" } }
}
li {
a href="/" { "Projects" }
a href="/projects" { "Projects" }
}
li {
a href=(project_base_path) { (project_short_name) }
+1 -1
View File
@@ -1 +1 @@
@import url('~uplot/dist/uPlot.min.css');
@import url("~uplot/dist/uPlot.min.css");
+4 -1
View File
@@ -69,7 +69,7 @@ $breakpoints: (
// Forms
"forms/basics": true,
"forms/checkbox-radio-switch": true,
"forms/input-color": false,
"forms/input-color": true,
"forms/input-date": false,
"forms/input-file": false,
"forms/input-range": false,
@@ -161,6 +161,9 @@ $breakpoints: (
.icon-github-circled:before { content: '\e804'; }
.icon-left-open:before { content: '\e805'; }
.icon-right-open:before { content: '\e806'; }
.icon-copy:before { content: '\e807'; }
.icon-cancel:before { content: '\e808'; }
.icon-code:before { content: '\e809'; }
.icon-gc:before { content: '\ea00'; }
.icon-wii:before { content: '\ea01'; }
+21
View File
@@ -0,0 +1,21 @@
.code-block {
position: relative;
}
.copy-button {
position: absolute;
top: 0.5em;
right: 0.5em;
padding: 0.5em;
vertical-align: bottom;
}
.clear-container {
display: flex;
gap: calc(var(--pico-spacing) * 0.5);
padding-top: calc(var(--pico-spacing) * 0.25);
input {
margin-bottom: 0;
}
}

Some files were not shown because too many files have changed in this diff Show More