diff --git a/Cargo.lock b/Cargo.lock index a8b6372..0588c70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 9a19a2c..b86332b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/assets/icons.woff2 b/assets/icons.woff2 index 34311ea..ab0cb94 100644 Binary files a/assets/icons.woff2 and b/assets/icons.woff2 differ diff --git a/biome.json b/biome.json index 67319cf..08ae778 100644 --- a/biome.json +++ b/biome.json @@ -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" - } + } } } } diff --git a/crates/core/src/util.rs b/crates/core/src/util.rs index b8b4e93..a747b6d 100644 --- a/crates/core/src/util.rs +++ b/crates/core/src/util.rs @@ -60,3 +60,16 @@ pub fn join_normalized(base: impl AsRef, path: impl AsRef) -> 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]) +} diff --git a/crates/github/graphql/queries.graphql b/crates/github/graphql/queries.graphql index fff3d79..c6aa2d7 100644 --- a/crates/github/graphql/queries.graphql +++ b/crates/github/graphql/queries.graphql @@ -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 -} \ No newline at end of file +} diff --git a/crates/github/src/webhook.rs b/crates/github/src/webhook.rs index d56a761..255cc18 100644 --- a/crates/github/src/webhook.rs +++ b/crates/github/src/webhook.rs @@ -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? diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index c64b2c3..42ee22f 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -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 + diff --git a/crates/images/src/badge.rs b/crates/images/src/badge.rs index 29a2bd8..5c2bfbb 100644 --- a/crates/images/src/badge.rs +++ b/crates/images/src/badge.rs @@ -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, } +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) = ¶ms.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, diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 3d3774d..a4e8112 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -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 diff --git a/crates/web/src/handlers/api.rs b/crates/web/src/handlers/api.rs new file mode 100644 index 0000000..571a071 --- /dev/null +++ b/crates/web/src/handlers/api.rs @@ -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, +) -> Result { + 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()) +} diff --git a/crates/web/src/handlers/common.rs b/crates/web/src/handlers/common.rs index 65f98ca..c05090a 100644 --- a/crates/web/src/handlers/common.rs +++ b/crates/web/src/handlers/common.rs @@ -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 { static ROBOTS_CACHE: LazyLock>> = LazyLock::new(|| std::sync::RwLock::new(None)); @@ -445,7 +455,7 @@ pub async fn get_robots() -> Result { } pub fn escape_script(text: &str) -> PreEscaped> { - static REGEX: LazyLock = LazyLock::new(|| Regex::new("<(?:!--|/?script)").unwrap()); + static REGEX: LazyLock = LazyLock::new(|| Regex::new("<(?i:!--|/?script)").unwrap()); PreEscaped(REGEX.replace_all(text, |caps: ®ex::Captures| format!("\\x3C{}", &caps[0][1..]))) } @@ -455,8 +465,8 @@ mod test { #[test] fn test_escape_script() { - let input = r#""#; - let expected = "\\x3C!--\\x3Cscript>alert('test');\\x3C/script>-->"; + let input = r#""#; + let expected = "\\x3C!--\\x3CSCRIPT>alert('test');\\x3C/ScRiPt>-->"; assert_eq!(escape_script(input).0.as_ref(), expected); } } diff --git a/crates/web/src/handlers/csp.rs b/crates/web/src/handlers/csp.rs index ec16ab3..895209c 100644 --- a/crates/web/src/handlers/csp.rs +++ b/crates/web/src/handlers/csp.rs @@ -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); diff --git a/crates/web/src/handlers/manage.rs b/crates/web/src/handlers/manage.rs index 30ddb98..26ad599 100644 --- a/crates/web/src/handlers/manage.rs +++ b/crates/web/src/handlers/manage.rs @@ -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::>(); 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::>(); 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" } diff --git a/crates/web/src/handlers/mod.rs b/crates/web/src/handlers/mod.rs index 3e5c029..e4e3a5e 100644 --- a/crates/web/src/handlers/mod.rs +++ b/crates/web/src/handlers/mod.rs @@ -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 { >::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 { .fallback_service(>::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 { .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(response: &mut Response) { + 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(response: &mut Response, value: HeaderValue) { + response.headers_mut().entry(header::CACHE_CONTROL).or_insert(value); +} + pub fn parse_accept(headers: &HeaderMap, ext: Option<&str>) -> Vec { // Explicit extension takes precedence if let Some(ext) = ext { diff --git a/crates/web/src/handlers/project.rs b/crates/web/src/handlers/project.rs index 2237417..f6a3322 100644 --- a/crates/web/src/handlers/project.rs +++ b/crates/web/src/handlers/project.rs @@ -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, code_progress: ProgressSections, } @@ -56,24 +62,113 @@ pub struct ProgressSection { pub tooltip: String, } +#[derive(serde::Serialize)] +pub struct ProjectsResponse { + pub projects: Vec, +} + +#[derive(serde::Serialize)] +pub struct ProjectResponse { + pub id: u64, + pub owner: String, + pub repo: String, + pub repo_url: String, + pub name: Option, + pub short_name: Option, + pub platform: Option, + pub default_version: Option, + pub default_category: Option, + pub commit: Option, + pub measures: TemplateMeasures, + pub report_versions: Vec, + pub report_categories: Vec, +} + +impl ProjectResponse { + pub fn new(info: &ProjectInfo, measures: &Measures, report: &ReportInner) -> 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, + pub timestamp: String, +} + +impl From 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 { + 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, Query(query): Query, FullUri(uri): FullUri, current_user: Option, + headers: HeaderMap, ) -> Result { + 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::>(); @@ -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::>(); + return Ok(Json(ProjectsResponse { projects }).into_response()); + } + } + Err(AppError::Status(StatusCode::NOT_ACCEPTABLE)) +} + +async fn render_project( + mut ctx: TemplateContext, + mut out: Vec, + uri: Uri, + current_sort: SortOption, + current_user: Option<&CurrentUser>, +) -> Result { + 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]) } } } } diff --git a/crates/web/src/handlers/report.rs b/crates/web/src/handlers/report.rs index 7214295..4554605 100644 --- a/crates/web/src/handlers/report.rs +++ b/crates/web/src/handlers/report.rs @@ -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, + owner: Option, + repo: Option, version: Option, commit: Option, } @@ -47,7 +51,9 @@ const DEFAULT_IMAGE_HEIGHT: u32 = 475; #[serde(rename_all = "camelCase")] pub struct ReportQuery { mode: Option, + version: Option, category: Option, + commit: Option, w: Option, h: Option, #[serde(flatten)] @@ -173,9 +179,23 @@ fn extract_extension(params: ReportParams) -> (ReportParams, Option) { ); } } - } 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(¶ms.owner, ¶ms.repo, commit).await? - else { + let Some(project_info) = (match (¶ms.id, ¶ms.owner, ¶ms.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) = ¶ms.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(¶ms.owner, ¶ms.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, +) -> Result { + 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, 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) } diff --git a/css/history.css b/css/history.css index 609cb9b..759bfa5 100644 --- a/css/history.css +++ b/css/history.css @@ -1 +1 @@ -@import url('~uplot/dist/uPlot.min.css'); +@import url("~uplot/dist/uPlot.min.css"); diff --git a/css/main.scss b/css/main.scss index 8fac4dc..0c42a85 100644 --- a/css/main.scss +++ b/css/main.scss @@ -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'; } diff --git a/js/api.module.css b/js/api.module.css new file mode 100644 index 0000000..7c25246 --- /dev/null +++ b/js/api.module.css @@ -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; + } +} diff --git a/js/api.tsx b/js/api.tsx new file mode 100644 index 0000000..8eca059 --- /dev/null +++ b/js/api.tsx @@ -0,0 +1,652 @@ +import { StrictMode, useState, useEffect, useMemo, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import hljs from 'highlight.js/lib/core'; +import 'highlight.js/styles/hybrid.css'; +import styles from './api.module.css'; + +hljs.registerLanguage('json', require('highlight.js/lib/languages/json')); +hljs.registerLanguage( + 'plaintext', + require('highlight.js/lib/languages/plaintext'), +); + +const CodeBlock = ({ + value, + language, + originalValue, +}: { value: string; language?: string | null; originalValue?: string }) => { + const codeRef = useRef(null); + const langClass = language ? `language-${language}` : undefined; + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (codeRef.current && !codeRef.current.dataset.highlighted) { + hljs.highlightElement(codeRef.current); + } + }, [value]); + + const copyToClipboard = () => { + navigator.clipboard.writeText(originalValue || value).catch((e) => { + console.error('Failed to copy text', e); + }); + }; + + return ( +
+      
+ ); +}; + +type ProjectsResponse = { + projects: ProjectResponse[]; +}; + +type ProjectResponse = { + id: string; + owner: string; + repo: string; + name: string | null; + short_name: string | null; + platform: string | null; + default_version: string; + report_versions: string[]; + report_categories: CategoryResponse[]; +}; + +type CategoryResponse = { + id: string; + name: string; +}; + +const fetchProjects = async () => { + const url = new URL('/projects', window.location.origin); + url.searchParams.append('sort', 'name'); + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error('Failed to fetch projects'); + } + return (await response.json()) as ProjectsResponse; +}; + +const fetchProjectById = async (id: string, version: string | null) => { + const url = new URL(`/projects/${id}`, window.location.origin); + if (version) { + url.searchParams.append('version', version); + } + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error('Failed to fetch projects'); + } + return (await response.json()) as ProjectResponse; +}; + +const fetchApiUrl = async (url: string) => { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error('Failed to fetch projects'); + } + return await response.text(); +}; + +const ProjectForm = () => { + const [projects, setProjects] = useState([]); + const [loadingProjects, setLoadingProjects] = useState(true); + const [loadingProject, setLoadingProject] = useState(true); + + type Selected = { + project: string | null; + version: string | null; + category: string | null; + }; + const [selected, setSelected] = useState({ + project: null, + version: null, + category: null, + }); + const [mode, setMode] = useState('overview'); + const [format, setFormat] = useState('json'); + const [formatOptions, setFormatOptions] = useState({}); + const [useProjectId, setUseProjectId] = useState(false); + + const [currentProject, setCurrentProject] = useState( + null, + ); + + useEffect(() => { + setLoadingProjects(true); + fetchProjects() + .then((response) => { + setProjects(response.projects); + const url = new URL(document.location.href); + const deferredProject = url.searchParams.get('project'); + if (deferredProject) { + const project = response.projects.find( + (p) => p.id.toString() === deferredProject, + ); + if (project) { + setCurrentProject(project); + setSelected({ + project: project.id, + version: null, + category: null, + }); + } + } + setLoadingProjects(false); + }) + .catch((error) => { + console.error('Error fetching projects:', error); + setProjects([]); + setLoadingProjects(false); + }); + }, []); + + useEffect(() => { + if (selected.project === null) { + return; + } + setLoadingProject(true); + fetchProjectById(selected.project, selected.version) + .then((response) => { + setCurrentProject(response); + setLoadingProject(false); + }) + .catch((error) => { + console.error('Error fetching project by ID:', error); + setCurrentProject(null); + setLoadingProject(false); + }); + }, [selected.project, selected.version]); + + const url = new URL('/', window.location.origin); + url.searchParams.append('mode', mode); + if (currentProject) { + if (useProjectId) { + url.pathname = `/projects/${currentProject.id}`; + if (selected.version) { + url.searchParams.append('version', selected.version); + } + } else { + url.pathname = `/${currentProject.owner}/${currentProject.repo}`; + if (selected.version) { + url.pathname += `/${selected.version || currentProject.default_version}`; + } + } + if (selected.category) { + url.searchParams.append('category', selected.category); + } + } + url.pathname += `.${format}`; + if (mode === 'shield') { + if (formatOptions.label) { + url.searchParams.append('label', formatOptions.label); + } + if (formatOptions.labelColor) { + url.searchParams.append('labelColor', formatOptions.labelColor); + } + if (formatOptions.color) { + url.searchParams.append('color', formatOptions.color); + } + if (formatOptions.style) { + url.searchParams.append('style', formatOptions.style); + } + if (formatOptions.measure) { + url.searchParams.append('measure', formatOptions.measure); + } + } + + const updateMode = (newMode: string) => { + setMode(newMode); + setFormat(formatsByMode[newMode][0]); + setFormatOptions({}); + }; + + const updateFormat = (newFormat: string) => { + setFormat(newFormat); + }; + + let modeOptions = null; + if (mode === 'shield') { + modeOptions = ( + <> +
+ +
+
+ + +
+
+ + +
+ + ); + } + + return ( +
+
+ + + +
+
+ + +
+
+ +
+
+ {modeOptions} + {currentProject && } +
+ ); +}; + +const formatsByMode: Record = { + overview: ['json', 'svg', 'png'], + measures: ['json', 'binpb'], + report: ['json', 'binpb'], + shield: ['svg', 'png', 'json'], + history: ['json'], +}; + +const prettifyJson = (data: string): string => { + let parsed: any; + try { + parsed = JSON.parse(data); + } catch (error) { + // Return the original data if parsing fails + return data; + } + return JSON.stringify(parsed, null, 2); +}; + +const ApiPreview = ({ + url, + format, +}: { url: string | null; format: string }) => { + type ResponseData = { + data: string; + format: string; + }; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [response, setResponse] = useState(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + setResponse(null); + }, [url]); + + const fetchData = async () => { + if (!url) { + return; + } + setLoading(true); + setError(null); + try { + const response = await fetchApiUrl(url); + setResponse({ data: response, format }); + } catch (error) { + console.error('Error fetching API URL:', error); + setError('Failed to fetch data'); + } finally { + setLoading(false); + } + }; + + const stringified = useMemo(() => { + if (!response) { + return ''; + } + const maxLength = 250000; // 250 KB + let stringified: string; + if (response.format === 'json') { + stringified = prettifyJson(response.data); + } else if (response.format === 'binpb') { + stringified = `// Binary protobuf size ${formatBytes(response.data.length)}`; + } else { + stringified = response.data; + } + if (stringified.length > maxLength) { + return `// Response was ${formatBytes(response.data.length)}, preview truncated to ${formatBytes(maxLength, 0)}\n${stringified.slice(0, maxLength)}`; + } + return stringified; + }, [response]); + + if (!url) { + return null; + } + + let preview = null; + if (response) { + if (response.format === 'json' || response.format === 'binpb') { + preview = ( + + ); + } + } + if (error) { + preview = ; + } + + if (format === 'svg' || format === 'png') { + preview = Preview; + } else if (format === 'json' || format === 'binpb') { + preview = ( + <> +
+ +
+
+ {preview} + + ); + } + + return ( + <> + + {preview} + + ); +}; + +function formatBytes(bytes: number, decimals = 2): string { + if (bytes === 0) return '0 B'; + + const k = 1000; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const sign = bytes < 0 ? '-' : ''; + const absoluteBytes = Math.abs(bytes); + const i = Math.min( + Math.floor(Math.log10(absoluteBytes) / Math.log10(k)), + sizes.length - 1, + ); + + const value = absoluteBytes / k ** i; + return `${sign}${value.toFixed(dm)} ${sizes[i]}`; +} + +const rootNode = document.getElementById('root'); +if (rootNode) { + const root = createRoot(rootNode); + root.render( + + + , + ); +} diff --git a/js/main.ts b/js/main.ts index f82758a..79f586f 100644 --- a/js/main.ts +++ b/js/main.ts @@ -1,87 +1,110 @@ const themeToggleButton = document.getElementById('theme-toggle'); if (themeToggleButton) { - themeToggleButton.addEventListener('click', (e) => { - const root = document.documentElement; - const defaultDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true; - const themeAttr = root.getAttribute('data-theme'); - const isDark = themeAttr === null ? defaultDark : themeAttr === 'dark'; - root.setAttribute('data-theme', isDark ? 'light' : 'dark'); - try { - localStorage.setItem('theme', isDark ? 'light' : 'dark'); - } catch (e) { - console.error('Failed to store theme', e); - } - e.preventDefault(); - }); + themeToggleButton.addEventListener('click', (e) => { + const root = document.documentElement; + const defaultDark = + window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true; + const themeAttr = root.getAttribute('data-theme'); + const isDark = themeAttr === null ? defaultDark : themeAttr === 'dark'; + root.setAttribute('data-theme', isDark ? 'light' : 'dark'); + try { + localStorage.setItem('theme', isDark ? 'light' : 'dark'); + } catch (e) { + console.error(e); + } + e.preventDefault(); + }); } let tooltipElem: HTMLElement | null = null; const applyTooltip = (elem: HTMLElement) => { - const text = elem.getAttribute('data-tooltip'); - if (!text) { - return; - } - let tooltip = document.getElementById('tooltip'); - if (!tooltip) { - tooltip = document.createElement('div'); - tooltip.id = 'tooltip'; - tooltip.addEventListener('transitionend', (e) => { - if (e.propertyName === 'opacity' && - e.target instanceof HTMLElement && - e.target.style.opacity === '0') { - e.target.remove(); - } - }); - document.body.appendChild(tooltip); - } - tooltip.innerText = text; - tooltip.style.opacity = '1'; - const rect = elem.getBoundingClientRect(); - let left = rect.left + window.scrollX + (rect.width - tooltip.offsetWidth) / 2; - const docWidth = document.documentElement.clientWidth || document.body.clientWidth; - if (left + tooltip.offsetWidth > docWidth) { - left = docWidth - tooltip.offsetWidth; - } - if (left < 0) { - left = 0; - } - let top = rect.top + window.scrollY - tooltip.offsetHeight; - if (top < 0) { - top = rect.bottom + window.scrollY; - } - tooltip.style.left = `${left}px`; - tooltip.style.top = `${top}px`; - const arrowLeft = rect.left + window.scrollX + rect.width / 2 - left; - tooltip.style.setProperty('--arrow-left', `${arrowLeft}px`); - tooltipElem = elem; + const text = elem.getAttribute('data-tooltip'); + if (!text) { + return; + } + let tooltip = document.getElementById('tooltip'); + if (!tooltip) { + tooltip = document.createElement('div'); + tooltip.id = 'tooltip'; + tooltip.addEventListener('transitionend', (e) => { + if ( + e.propertyName === 'opacity' && + e.target instanceof HTMLElement && + e.target.style.opacity === '0' + ) { + e.target.remove(); + } + }); + document.body.appendChild(tooltip); + } + tooltip.innerText = text; + tooltip.style.opacity = '1'; + const rect = elem.getBoundingClientRect(); + const scrollGutter = + document.documentElement.clientWidth - document.body.clientWidth; + const scrollX = window.scrollX - scrollGutter; + let left = rect.left + scrollX + (rect.width - tooltip.offsetWidth) / 2; + const docWidth = + document.documentElement.clientWidth || document.body.clientWidth; + if (left + tooltip.offsetWidth > docWidth) { + left = docWidth - tooltip.offsetWidth; + } + if (left < 0) { + left = 0; + } + let top = rect.top + window.scrollY - tooltip.offsetHeight; + if (top < 0) { + top = rect.bottom + window.scrollY; + } + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + const arrowLeft = rect.left + scrollX + rect.width / 2 - left; + tooltip.style.setProperty('--arrow-left', `${arrowLeft}px`); + tooltipElem = elem; }; const removeTooltip = (elem: HTMLElement) => { - if (elem !== tooltipElem) { - return; - } - const tooltip = document.getElementById('tooltip'); - if (tooltip) { - tooltip.style.opacity = '0'; - } - tooltipElem = null; + if (elem !== tooltipElem) { + return; + } + const tooltip = document.getElementById('tooltip'); + if (tooltip) { + tooltip.style.opacity = '0'; + } + tooltipElem = null; }; -document.addEventListener('mouseover', (e) => { +document.addEventListener( + 'mouseover', + (e) => { if (e.target instanceof HTMLElement) { - applyTooltip(e.target); + applyTooltip(e.target); } -}, { passive: true }); -document.addEventListener('mouseout', (e) => { + }, + { passive: true }, +); +document.addEventListener( + 'mouseout', + (e) => { if (e.target instanceof HTMLElement) { - removeTooltip(e.target); + removeTooltip(e.target); } -}, { passive: true }); -document.addEventListener('touchstart', (e) => { + }, + { passive: true }, +); +document.addEventListener( + 'touchstart', + (e) => { if (e.target instanceof HTMLElement) { - applyTooltip(e.target); + applyTooltip(e.target); } -}, { passive: true }); -document.addEventListener('touchend', (e) => { + }, + { passive: true }, +); +document.addEventListener( + 'touchend', + (e) => { if (e.target instanceof HTMLElement) { - removeTooltip(e.target); + removeTooltip(e.target); } -}, { passive: true }); + }, + { passive: true }, +); diff --git a/js/manage.ts b/js/manage.ts index eb8c64b..b26334e 100644 --- a/js/manage.ts +++ b/js/manage.ts @@ -1,12 +1,12 @@ document.querySelectorAll('form[data-loading]').forEach((form) => { - const loadingText = form.getAttribute('data-loading'); - const submitButton = form.querySelector('[type="submit"]'); - if (!submitButton || !(submitButton instanceof HTMLButtonElement)) { - return; - } - form.addEventListener('submit', () => { - submitButton.disabled = true; - submitButton.setAttribute('aria-busy', 'true'); - submitButton.innerText = loadingText || 'Loading...'; - }); + const loadingText = form.getAttribute('data-loading'); + const submitButton = form.querySelector('[type="submit"]'); + if (!submitButton || !(submitButton instanceof HTMLButtonElement)) { + return; + } + form.addEventListener('submit', () => { + submitButton.disabled = true; + submitButton.setAttribute('aria-busy', 'true'); + submitButton.innerText = loadingText || 'Loading...'; + }); }); diff --git a/js/treemap.ts b/js/treemap.ts index 3badc47..7fa5fcb 100644 --- a/js/treemap.ts +++ b/js/treemap.ts @@ -1,114 +1,128 @@ const unitBounds = (unit: Unit, width: number, height: number) => { - return { - x: unit.x * width, - y: unit.y * height, - w: unit.w * width, - h: unit.h * height, - }; -} + return { + x: unit.x * width, + y: unit.y * height, + w: unit.w * width, + h: unit.h * height, + }; +}; const BORDER_RADIUS = 5; const PADDING_W = 10; const PADDING_H = 5; const MARGIN = 5; -const ellipsize = (ctx: CanvasRenderingContext2D, text: string, width: number) => { - const ellipsis = "…"; - const padding = PADDING_W * 2; - let m = ctx.measureText(text); +const ellipsize = ( + ctx: CanvasRenderingContext2D, + text: string, + width: number, +) => { + const ellipsis = '…'; + const padding = PADDING_W * 2; + let m = ctx.measureText(text); + if (m.actualBoundingBoxRight + m.actualBoundingBoxLeft + padding <= width) { + return text; + } + let n = 3; + while (true) { + const start = text.length / 4 - n / 4; + const ellipsized = text.slice(0, start) + ellipsis + text.slice(start + n); + m = ctx.measureText(ellipsized); if (m.actualBoundingBoxRight + m.actualBoundingBoxLeft + padding <= width) { - return text; - } - let n = 3; - while (true) { - const start = text.length / 4 - n / 4; - const ellipsized = text.slice(0, start) + ellipsis + text.slice(start + n); - m = ctx.measureText(ellipsized); - if (m.actualBoundingBoxRight + m.actualBoundingBoxLeft + padding <= width) { - return ellipsized; - } - n++; + return ellipsized; } + n++; + } }; -const UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; +const UNITS = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const formatSize = (value: number): string => { - let unit = 0; - while (value >= 1000.0 && unit < UNITS.length - 1) { - // biome-ignore lint/style/noParameterAssign: don't care - value /= 1000.0; - unit += 1; - } - return `${value.toFixed(2)} ${UNITS[unit]}`; + let unit = 0; + while (value >= 1000.0 && unit < UNITS.length - 1) { + // biome-ignore lint/style/noParameterAssign: don't care + value /= 1000.0; + unit += 1; + } + return `${value.toFixed(2)} ${UNITS[unit]}`; }; -const drawTooltip = (ctx: CanvasRenderingContext2D, unit: Unit, width: number, height: number) => { - const style = getComputedStyle(ctx.canvas); - const fontWeight = style.getPropertyValue('--font-weight') || 'normal'; - const fontSize = style.getPropertyValue('--font-size') || '16px'; - const fontFamily = style.getPropertyValue('--font-family') || 'sans-serif'; - const tooltipBackground = style.getPropertyValue('--tooltip-background') || "#fff" - const tooltipColor = style.getPropertyValue('--tooltip-color') || "#000" - ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`; - ctx.textBaseline = "middle"; +const drawTooltip = ( + ctx: CanvasRenderingContext2D, + unit: Unit, + width: number, + height: number, +) => { + const style = getComputedStyle(ctx.canvas); + const fontWeight = style.getPropertyValue('--font-weight') || 'normal'; + const fontSize = style.getPropertyValue('--font-size') || '16px'; + const fontFamily = style.getPropertyValue('--font-family') || 'sans-serif'; + const tooltipBackground = + style.getPropertyValue('--tooltip-background') || '#fff'; + const tooltipColor = style.getPropertyValue('--tooltip-color') || '#000'; + ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`; + ctx.textBaseline = 'middle'; - const {x, y, w, h} = unitBounds(unit, width, height); - const text = ellipsize(ctx, `${unit.name} • ${formatSize(unit.total_code)} • ${unit.fuzzy_match_percent.toFixed(2)}%`, width); - const m = ctx.measureText(text); - const bw = m.actualBoundingBoxRight + m.actualBoundingBoxLeft + PADDING_W * 2; - const bh = m.fontBoundingBoxAscent + m.fontBoundingBoxDescent + PADDING_H * 2; - let bx = x + (w - bw) / 2; - let by = y - bh - MARGIN; - let ay = y; - if (isTouch) { - bx = (width - bw) / 2; - if (y + h / 2 < height / 2) { - // Draw at the bottom - by = height - bh - MARGIN; - } else { - // Draw at the top - by = MARGIN; - } + const { x, y, w, h } = unitBounds(unit, width, height); + const text = ellipsize( + ctx, + `${unit.name} • ${formatSize(unit.total_code)} • ${unit.fuzzy_match_percent.toFixed(2)}%`, + width, + ); + const m = ctx.measureText(text); + const bw = m.actualBoundingBoxRight + m.actualBoundingBoxLeft + PADDING_W * 2; + const bh = m.fontBoundingBoxAscent + m.fontBoundingBoxDescent + PADDING_H * 2; + let bx = x + (w - bw) / 2; + let by = y - bh - MARGIN; + let ay = y; + if (isTouch) { + bx = (width - bw) / 2; + if (y + h / 2 < height / 2) { + // Draw at the bottom + by = height - bh - MARGIN; } else { - if (bx + bw > width) { - bx = width - bw; - } - if (bx < 0) { - bx = 0; - } - if (by < 0) { - // Draw below the box - by = y + h + MARGIN; - ay = y + h; - } - if (by + bh > height) { - // Draw inside the box - by = y + MARGIN; - ay = y; - } + // Draw at the top + by = MARGIN; } - ctx.fillStyle = tooltipBackground; - ctx.beginPath(); - ctx.roundRect(bx, by, bw, bh, BORDER_RADIUS); - if (!isTouch) { - // Arrow - const ax = x + w / 2; - if (ay < by) { - // Top - ctx.moveTo(ax, ay); - ctx.lineTo(ax + MARGIN, by); - ctx.lineTo(ax - MARGIN, by); - } else { - // Bottom - ctx.moveTo(ax, ay); - ctx.lineTo(ax + MARGIN, by + bh); - ctx.lineTo(ax - MARGIN, by + bh); - } + } else { + if (bx + bw > width) { + bx = width - bw; } - ctx.fill(); - ctx.fillStyle = tooltipColor; - ctx.fillText(text, bx + PADDING_W, by + bh / 2); + if (bx < 0) { + bx = 0; + } + if (by < 0) { + // Draw below the box + by = y + h + MARGIN; + ay = y + h; + } + if (by + bh > height) { + // Draw inside the box + by = y + MARGIN; + ay = y; + } + } + ctx.fillStyle = tooltipBackground; + ctx.beginPath(); + ctx.roundRect(bx, by, bw, bh, BORDER_RADIUS); + if (!isTouch) { + // Arrow + const ax = x + w / 2; + if (ay < by) { + // Top + ctx.moveTo(ax, ay); + ctx.lineTo(ax + MARGIN, by); + ctx.lineTo(ax - MARGIN, by); + } else { + // Bottom + ctx.moveTo(ax, ay); + ctx.lineTo(ax + MARGIN, by + bh); + ctx.lineTo(ax - MARGIN, by + bh); + } + } + ctx.fill(); + ctx.fillStyle = tooltipColor; + ctx.fillText(text, bx + PADDING_W, by + bh / 2); }; let hovered: Unit | null = null; @@ -116,152 +130,177 @@ let dirty = false; let isTouch = false; let cachedCanvas: HTMLCanvasElement | null = null; -const setup = (ctx: CanvasRenderingContext2D, ratio: number, width: number, height: number) => { - ctx.setTransform(ratio, 0, 0, ratio, 0, 0); // Scale to device pixel ratio - ctx.clearRect(0, 0, width, height); - ctx.lineWidth = 1; - ctx.strokeStyle = "#000"; -} - -const drawUnits = (ctx: CanvasRenderingContext2D, units: Unit[], width: number, height: number) => { - for (const unit of units) { - const {x, y, w, h} = unitBounds(unit, width, height); - ctx.fillStyle = unit.color; - ctx.beginPath(); - ctx.rect(x, y, w, h); - ctx.fill(); - ctx.stroke(); - } -} - -const draw = (canvas: HTMLCanvasElement, units: Unit[]) => { - const {width, height} = canvas.getBoundingClientRect(); - const ratio = window.devicePixelRatio; - const renderWidth = width * ratio; - const renderHeight = height * ratio; - if (!dirty && canvas.width === renderWidth && canvas.height === renderHeight) { - // Nothing changed - return; - } - dirty = false; - // High DPI support - if (canvas.width !== renderWidth || canvas.height !== renderHeight) { - canvas.width = renderWidth; - canvas.height = renderHeight; - } - // Update cached canvas if needed - if (!cachedCanvas) { - cachedCanvas = document.createElement("canvas"); - } - if (cachedCanvas.width !== renderWidth || cachedCanvas.height !== renderHeight) { - cachedCanvas.width = renderWidth; - cachedCanvas.height = renderHeight; - const cachedCtx = cachedCanvas.getContext("2d"); - if (!cachedCtx) { - return; - } - setup(cachedCtx, ratio, width, height); - drawUnits(cachedCtx, units, width, height); - } - const ctx = canvas.getContext("2d"); - if (!ctx) { - return; - } - // Use 1:1 scale for rendering cached canvas - setup(ctx, 1, renderWidth, renderHeight); - ctx.drawImage(cachedCanvas, 0, 0); - ctx.scale(ratio, ratio); // Restore device scale - if (hovered) { - const {x, y, w, h} = unitBounds(hovered, width, height); - ctx.lineWidth = 2; - ctx.strokeStyle = "#fff"; - ctx.strokeRect(x, y, w, h); - drawTooltip(ctx, hovered, width, height); - } +const setup = ( + ctx: CanvasRenderingContext2D, + ratio: number, + width: number, + height: number, +) => { + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); // Scale to device pixel ratio + ctx.clearRect(0, 0, width, height); + ctx.lineWidth = 1; + ctx.strokeStyle = '#000'; }; -const findUnit = (canvas: HTMLCanvasElement, units: Unit[], clientX: number, clientY: number): Unit | null => { - const {width, height, left, top} = canvas.getBoundingClientRect(); - const mx = clientX - left; - const my = clientY - top; - for (const unit of units) { - const {x, y, w, h} = unitBounds(unit, width, height); - if (mx >= x && mx <= x + w && my >= y && my <= y + h) { - return unit; - } +const drawUnits = ( + ctx: CanvasRenderingContext2D, + units: Unit[], + width: number, + height: number, +) => { + for (const unit of units) { + const { x, y, w, h } = unitBounds(unit, width, height); + ctx.fillStyle = unit.color; + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.fill(); + ctx.stroke(); + } +}; + +const draw = (canvas: HTMLCanvasElement, units: Unit[]) => { + const { width, height } = canvas.getBoundingClientRect(); + const ratio = window.devicePixelRatio; + const renderWidth = Math.round(width * ratio); + const renderHeight = Math.round(height * ratio); + if ( + !dirty && + canvas.width === renderWidth && + canvas.height === renderHeight + ) { + // Nothing changed + return; + } + dirty = false; + // High DPI support + if (canvas.width !== renderWidth || canvas.height !== renderHeight) { + canvas.width = renderWidth; + canvas.height = renderHeight; + } + // Update cached canvas if needed + if (!cachedCanvas) { + cachedCanvas = document.createElement('canvas'); + } + if ( + cachedCanvas.width !== renderWidth || + cachedCanvas.height !== renderHeight + ) { + cachedCanvas.width = renderWidth; + cachedCanvas.height = renderHeight; + const cachedCtx = cachedCanvas.getContext('2d'); + if (!cachedCtx) { + return; } - return null; -} + setup(cachedCtx, ratio, width, height); + drawUnits(cachedCtx, units, width, height); + } + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + // Use 1:1 scale for rendering cached canvas + setup(ctx, 1, renderWidth, renderHeight); + ctx.drawImage(cachedCanvas, 0, 0); + ctx.scale(ratio, ratio); // Restore device scale + if (hovered) { + const { x, y, w, h } = unitBounds(hovered, width, height); + ctx.lineWidth = 2; + ctx.strokeStyle = '#fff'; + ctx.strokeRect(x, y, w, h); + drawTooltip(ctx, hovered, width, height); + } +}; + +const findUnit = ( + canvas: HTMLCanvasElement, + units: Unit[], + clientX: number, + clientY: number, +): Unit | null => { + const { width, height, left, top } = canvas.getBoundingClientRect(); + const mx = clientX - left; + const my = clientY - top; + for (const unit of units) { + const { x, y, w, h } = unitBounds(unit, width, height); + if (mx >= x && mx <= x + w && my >= y && my <= y + h) { + return unit; + } + } + return null; +}; const drawTreemap = (id: string, clickable: boolean, units: Unit[]) => { - const canvas = document.getElementById(id) as HTMLCanvasElement; - if (!canvas || !canvas.getContext) { - return; + const canvas = document.getElementById(id) as HTMLCanvasElement; + if (!canvas || !canvas.getContext) { + return; + } + const queueDraw = () => requestAnimationFrame(() => draw(canvas, units)); + const resizeObserver = new ResizeObserver(queueDraw); + resizeObserver.observe(canvas); + const handleHover = ({ + clientX, + clientY, + }: { clientX: number; clientY: number }) => { + const unit = findUnit(canvas, units, clientX, clientY); + if (unit === hovered) { + return; } - const queueDraw = () => requestAnimationFrame(() => draw(canvas, units)); - const resizeObserver = new ResizeObserver(queueDraw); - resizeObserver.observe(canvas); - const handleHover = ({clientX, clientY}: { clientX: number, clientY: number }) => { - const unit = findUnit(canvas, units, clientX, clientY); - if (unit === hovered) { - return; - } - if (clickable) { - canvas.style.cursor = unit ? "pointer" : "default"; - } - hovered = unit; - dirty = true; - queueDraw(); + if (clickable) { + canvas.style.cursor = unit ? 'pointer' : 'default'; } - const handleLeave = () => { - if (!hovered) { - return; - } - if (clickable) { - canvas.style.cursor = "default"; - } - hovered = null; - dirty = true; - queueDraw(); - }; - canvas.addEventListener("mousemove", (e) => { - isTouch = false; - handleHover(e); - }); - canvas.addEventListener("mouseleave", handleLeave); - canvas.addEventListener("touchmove", (e) => { - isTouch = true; - handleHover(e.touches[0]); - }); - canvas.addEventListener("touchend", handleLeave); - canvas.addEventListener("click", ({clientX, clientY}) => { - const unit = findUnit(canvas, units, clientX, clientY); - if (!unit || !unit.name || !clickable) { - return; - } - const url = new URL(window.location.href); - url.searchParams.set("unit", unit.name); - window.location.href = url.toString(); - }); - updatePixelRatio(queueDraw, false); - draw(canvas, units); + hovered = unit; + dirty = true; + queueDraw(); + }; + const handleLeave = () => { + if (!hovered) { + return; + } + if (clickable) { + canvas.style.cursor = 'default'; + } + hovered = null; + dirty = true; + queueDraw(); + }; + canvas.addEventListener('mousemove', (e) => { + isTouch = false; + handleHover(e); + }); + canvas.addEventListener('mouseleave', handleLeave); + canvas.addEventListener('touchmove', (e) => { + isTouch = true; + handleHover(e.touches[0]); + }); + canvas.addEventListener('touchend', handleLeave); + canvas.addEventListener('click', ({ clientX, clientY }) => { + const unit = findUnit(canvas, units, clientX, clientY); + if (!unit || !unit.name || !clickable) { + return; + } + const url = new URL(window.location.href); + url.searchParams.set('unit', unit.name); + window.location.href = url.toString(); + }); + updatePixelRatio(queueDraw, false); + draw(canvas, units); }; let remove: (() => void) | null = null; const updatePixelRatio = (redraw: () => void, now: boolean) => { - if (remove != null) { - remove(); - } - const media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); - const cb = () => updatePixelRatio(redraw, true); - media.addEventListener("change", cb); - remove = () => { - media.removeEventListener("change", cb); - }; - if (now) { - redraw(); - } + if (remove != null) { + remove(); + } + const media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + const cb = () => updatePixelRatio(redraw, true); + media.addEventListener('change', cb); + remove = () => { + media.removeEventListener('change', cb); + }; + if (now) { + redraw(); + } }; window.drawTreemap = drawTreemap; diff --git a/package-lock.json b/package-lock.json index 2270ea2..39c96f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,13 +6,18 @@ "": { "dependencies": { "@picocss/pico": "^2.0.6", + "highlight.js": "^11.11.1", + "react-dom": "^19.1.0", "uplot": "^1.6.32" }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@rsbuild/core": "^1.3.12", + "@rsbuild/plugin-react": "^1.3.0", "@rsbuild/plugin-sass": "^1.3.1", - "@rsbuild/plugin-type-check": "^1.2.1" + "@rsbuild/plugin-type-check": "^1.2.1", + "@types/node": "^22.15.14", + "@types/react-dom": "^19.1.3" } }, "node_modules/@babel/code-frame": { @@ -356,6 +361,20 @@ "node": ">=16.10.0" } }, + "node_modules/@rsbuild/plugin-react": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rsbuild/plugin-react/-/plugin-react-1.3.0.tgz", + "integrity": "sha512-3LxUoRTGDH9KB5ol5+4792DFJU5w3PKCcbF9QVRbL2Ma31WP/cEETTN7m5uZYyUZN/hOwrxU+ueRNNKD3XtaYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rspack/plugin-react-refresh": "~1.4.1", + "react-refresh": "^0.17.0" + }, + "peerDependencies": { + "@rsbuild/core": "1.x" + } + }, "node_modules/@rsbuild/plugin-sass": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@rsbuild/plugin-sass/-/plugin-sass-1.3.1.tgz", @@ -572,6 +591,26 @@ "node": ">=16.0.0" } }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.4.1.tgz", + "integrity": "sha512-bRALP4qEauvrB7RcMQ95rUHu1dw19V6c6uYukUTpA2OZDyMHTQ9cpEe28kaDwH/xsAuDNuYqnUZOW3NdLO/q3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.1.4", + "html-entities": "^2.6.0" + }, + "peerDependencies": { + "react-refresh": ">=0.10.0 <1.0.0", + "webpack-hot-middleware": "2.x" + }, + "peerDependenciesMeta": { + "webpack-hot-middleware": { + "optional": true + } + } + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -582,6 +621,37 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/node": { + "version": "22.15.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.14.tgz", + "integrity": "sha512-BL1eyu/XWsFGTtDWOYULQEs4KR0qdtYfCxYAUYRoB7JP7h9ETYLgQTww6kH8Sj2C0pFGgrpM0XKv6/kbIzYJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.3.tgz", + "integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.3.tgz", + "integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -721,6 +791,14 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -741,6 +819,16 @@ "node": ">= 4" } }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -792,6 +880,32 @@ "node": ">=8" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/hyperdyperid": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", @@ -1014,6 +1128,38 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1429,6 +1575,12 @@ "node": ">=14.0.0" } }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1439,6 +1591,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -1570,6 +1729,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/uplot": { "version": "1.6.32", "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", diff --git a/package.json b/package.json index 79f1980..a886881 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,17 @@ }, "dependencies": { "@picocss/pico": "^2.0.6", + "highlight.js": "^11.11.1", + "react-dom": "^19.1.0", "uplot": "^1.6.32" }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@rsbuild/core": "^1.3.12", + "@rsbuild/plugin-react": "^1.3.0", "@rsbuild/plugin-sass": "^1.3.1", - "@rsbuild/plugin-type-check": "^1.2.1" + "@rsbuild/plugin-type-check": "^1.2.1", + "@types/node": "^22.15.14", + "@types/react-dom": "^19.1.3" } } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 79db12f..8a1a896 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png deleted file mode 100644 index 381683a..0000000 Binary files a/public/favicon-96x96.png and /dev/null differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..f3e8cb8 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/web-app-manifest-192x192.png b/public/web-app-manifest-192x192.png index 60ab120..6c92d1d 100644 Binary files a/public/web-app-manifest-192x192.png and b/public/web-app-manifest-192x192.png differ diff --git a/public/web-app-manifest-512x512.png b/public/web-app-manifest-512x512.png index d21514d..be92907 100644 Binary files a/public/web-app-manifest-512x512.png and b/public/web-app-manifest-512x512.png differ diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 6a49487..459b7cd 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@rsbuild/core'; import { pluginSass } from '@rsbuild/plugin-sass'; import { pluginTypeCheck } from '@rsbuild/plugin-type-check'; +import { pluginReact } from '@rsbuild/plugin-react'; export default defineConfig({ source: { @@ -10,6 +11,7 @@ export default defineConfig({ history: ['./js/history.ts', './css/history.css'], manage: ['./js/manage.ts'], report: ['./js/treemap.ts'], + api: ['./js/api.tsx'], }, }, output: { @@ -36,7 +38,7 @@ export default defineConfig({ }, }, }, - plugins: [pluginSass(), pluginTypeCheck()], + plugins: [pluginSass(), pluginTypeCheck(), pluginReact()], server: { port: 3001, }, diff --git a/tsconfig.json b/tsconfig.json index 47534fe..c1af2df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,24 +1,23 @@ { - "compilerOptions": { - "lib": ["DOM", "ES2023"], - "jsx": "react-jsx", - "target": "ES2022", - "noEmit": true, - "skipLibCheck": true, - "useDefineForClassFields": true, - - /* modules */ - "module": "ESNext", - "isolatedModules": true, - "resolveJsonModule": true, - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - - /* type checking */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true - }, - "include": ["js"] - } - \ No newline at end of file + "compilerOptions": { + "lib": ["DOM", "ES2023"], + "jsx": "react-jsx", + "target": "ES2022", + "noEmit": true, + "skipLibCheck": true, + "useDefineForClassFields": true, + + /* modules */ + "module": "ESNext", + "isolatedModules": true, + "resolveJsonModule": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + + /* type checking */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["js"] +}