diff --git a/README.md b/README.md index 5b971fb..d1f4d6f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,18 @@ endpoints, renders only MoonBit source/project files, and never accepts, stores, or sends a personal access token. Anonymous GitHub API rate limits therefore apply. +Submitting a GitHub URL updates the browser to a static-host-friendly share +route: + +```text +https://{playground-host}/{base}/#/owner/repo/commit/sha +``` + +Opening that URL restores and loads the same commit automatically. The result +page also exposes the full URL in a read-only field with a one-click copy +button. Hash routing keeps shared links working on GitHub Pages without a +server-side rewrite rule. + Run the live development server from the Warren app directory: ```sh diff --git a/playground/main/logic_wbtest.mbt b/playground/main/logic_wbtest.mbt index 4b5b010..cb96887 100644 --- a/playground/main/logic_wbtest.mbt +++ b/playground/main/logic_wbtest.mbt @@ -49,6 +49,45 @@ test "parse supported GitHub commit URL forms" { assert_eq(pr.sha, "0123456789abcdef") } +///| +test "shared playground routes round-trip to canonical GitHub commit URLs" { + let reference = CommitRef::{ + owner: "moonbit-community", + repo: "ldiff", + sha: "aBcDeF1", + } + assert_eq( + playground_route(reference), + "#/moonbit-community/ldiff/commit/aBcDeF1", + ) + assert_eq( + canonical_github_url(reference), + "https://github.com/moonbit-community/ldiff/commit/aBcDeF1", + ) + guard reference_from_playground_url( + "https://example.github.io/ldiff/#/moonbit-community/ldiff/commit/aBcDeF1", + ) + is Ok(Some(parsed)) else { + fail("expected the shared route to parse") + } + assert_true(parsed == reference) + assert_true( + reference_from_playground_url("https://example.com/ldiff/") is Ok(None), + ) + assert_true( + reference_from_playground_url( + "https://example.com/ldiff/#/moonbit-community/ldiff/pull/1", + ) + is Err(_), + ) + assert_true( + reference_from_playground_url( + "https://example.com/ldiff/#/bad owner/ldiff/commit/abcdef1", + ) + is Err(_), + ) +} + ///| test "reject unsupported or malformed URLs" { for @@ -132,7 +171,7 @@ test "pagination appends file metadata and stops at GitHub's 30-page cap" { assert_false(should_fetch_next_page(1, 99)) assert_false(should_fetch_next_page(30, 100)) assert_true( - commit_api_url(CommitRef::{ owner: "a", repo: "b", sha: "abcdef1" }, 30).has_suffix( + commit_api_url({ owner: "a", repo: "b", sha: "abcdef1" }, 30).has_suffix( "?per_page=100&page=30", ), ) @@ -197,11 +236,14 @@ test "stale commit responses are discarded by generation" { generation: 2, view_mode: Split, phase: Idle, + route: None, + share_url: None, + copy_status: CopyReady, } let fixture = decode_commit( read_fixture("playground/main/fixtures/root_commit.json"), ) - let emit : @rabbita.Emit[Msg] = @cmd.Emit(_ => @rabbita.none) + let emit : @rabbita.Emit[Msg] = Emit(_ => @rabbita.none) let (after, _) = update(model, CommitPageLoaded(1, 1, Ok(fixture)), emit) assert_true(after == model) } @@ -215,7 +257,7 @@ test "only the first twenty matching files auto-load and stale source replies ar for index in 0..<21 { files.push(fixture_file("src/file_\{index}.mbt", "added")) } - let emit : @rabbita.Emit[Msg] = @cmd.Emit(_ => @rabbita.none) + let emit : @rabbita.Emit[Msg] = Emit(_ => @rabbita.none) let reference = CommitRef::{ owner: "example", repo: "root", @@ -235,6 +277,9 @@ test "only the first twenty matching files auto-load and stale source replies ar generation: 7, view_mode: Split, phase: Showing(view), + route: Some(reference), + share_url: None, + copy_status: CopyReady, } let (after, _) = update(model, SourceLoaded(6, 0, New, Ok("stale")), emit) assert_true(after == model) diff --git a/playground/main/main.mbt b/playground/main/main.mbt index 33804d8..9c9e295 100644 --- a/playground/main/main.mbt +++ b/playground/main/main.mbt @@ -8,5 +8,12 @@ ///| fn main { - @rabbita.elmish(model=initial_model(), view=app_view, update~).mount("app") + @rabbita.new(() => { + let (model, emit) = @rabbita.create_state_with_init( + init=app_init, + update~, + subscriptions=app_subscriptions, + ) + model.map(model => app_view(model, emit)) + }).mount("app") } diff --git a/playground/main/moon.pkg b/playground/main/moon.pkg index b943ef9..037e200 100644 --- a/playground/main/moon.pkg +++ b/playground/main/moon.pkg @@ -1,8 +1,11 @@ import { "moonbit-community/ldiff", "moonbit-community/rabbita", + "moonbit-community/rabbita/clipboard", "moonbit-community/rabbita/html", "moonbit-community/rabbita/http", + "moonbit-community/rabbita/nav", + "moonbit-community/rabbita/sub", "moonbit-community/rabbita/url", "moonbitlang/core/encoding/utf8", "moonbitlang/core/json", diff --git a/playground/main/share_url.mbt b/playground/main/share_url.mbt new file mode 100644 index 0000000..081321a --- /dev/null +++ b/playground/main/share_url.mbt @@ -0,0 +1,58 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +///| +fn canonical_github_url(reference : CommitRef) -> String { + "https://github.com/\{reference.owner}/\{reference.repo}/commit/\{reference.sha}" +} + +///| +fn playground_route(reference : CommitRef) -> String { + "#/\{reference.owner}/\{reference.repo}/commit/\{reference.sha}" +} + +///| +fn reference_from_route(fragment : String?) -> Result[CommitRef?, String] { + guard fragment is Some(value) && !value.is_empty() else { return Ok(None) } + let parts : Array[String] = value + .split("/") + .map(StringView::to_owned) + .collect() + let reference = match parts { + ["", owner, repo, "commit", sha] | [owner, repo, "commit", sha] => + CommitRef::{ owner, repo, sha } + _ => return Err("This shared playground link is not valid.") + } + if !valid_owner(reference.owner) || + !valid_repo(reference.repo) || + !valid_sha(reference.sha) { + return Err("This shared playground link is not valid.") + } + Ok(Some(reference)) +} + +///| +fn reference_from_playground_url(input : String) -> Result[CommitRef?, String] { + let parsed = try @url.parse(input) catch { + _ => return Err("This shared playground link is not valid.") + } noraise { + parsed => parsed + } + reference_from_route(parsed.fragment) +} + +///| +extern "js" fn browser_current_url() -> String = "() => window.location.href" + +///| +extern "js" fn browser_base_url() -> String = "() => window.location.origin + window.location.pathname" + +///| +fn playground_share_url(reference : CommitRef) -> String { + "\{browser_base_url()}\{playground_route(reference)}" +} diff --git a/playground/main/state.mbt b/playground/main/state.mbt index e4419fd..fff8dd5 100644 --- a/playground/main/state.mbt +++ b/playground/main/state.mbt @@ -60,24 +60,46 @@ priv enum Phase { Showing(CommitView) } derive(Eq) +///| +priv enum CopyStatus { + CopyReady + Copied + CopyError(String) +} derive(Eq) + ///| priv struct Model { input : String generation : Int view_mode : ViewMode phase : Phase + route : CommitRef? + share_url : String? + copy_status : CopyStatus } derive(Eq) ///| fn initial_model() -> Model { - { input: "", generation: 0, view_mode: Split, phase: Idle } + { + input: "", + generation: 0, + view_mode: Split, + phase: Idle, + route: None, + share_url: None, + copy_status: CopyReady, + } } ///| priv enum Msg { InputChanged(String) Submit + RouteChanged(@url.Url) ToggleView + CopyShareLink + ShareCopied + ShareCopyFailed(String) CommitPageLoaded(Int, Int, Result[ApiCommit, Error]) ToggleFile(Int, Int) RetryFile(Int, Int) @@ -228,9 +250,48 @@ fn with_phase(model : Model, phase : Phase) -> Model { generation: model.generation, view_mode: model.view_mode, phase, + route: model.route, + share_url: model.share_url, + copy_status: model.copy_status, + } +} + +///| +fn begin_commit_load( + model : Model, + reference : CommitRef, + emit : @rabbita.Emit[Msg], +) -> (Model, @rabbita.Cmd) { + let generation = model.generation + 1 + ( + { + input: canonical_github_url(reference), + generation, + view_mode: model.view_mode, + phase: LoadingCommit(reference~, page=1, first=None, files=[]), + route: Some(reference), + share_url: Some(playground_share_url(reference)), + copy_status: CopyReady, + }, + commit_request(reference, generation, 1, emit), + ) +} + +///| +fn app_init(emit : @rabbita.Emit[Msg]) -> (Model, @rabbita.Cmd) { + let model = initial_model() + match reference_from_playground_url(browser_current_url()) { + Ok(Some(reference)) => begin_commit_load(model, reference, emit) + Ok(None) => (model, @rabbita.none) + Err(message) => (with_phase(model, LoadFailed(message)), @rabbita.none) } } +///| +fn app_subscriptions(_model : Model, emit : @rabbita.Emit[Msg]) -> @sub.Sub { + @sub.on_url_changed(url => emit(RouteChanged(url))) +} + ///| fn side_from_result(result : Result[String, Error]) -> SideLoad { match result { @@ -257,6 +318,9 @@ fn update( generation: model.generation, view_mode: model.view_mode, phase: model.phase, + route: model.route, + share_url: model.share_url, + copy_status: CopyReady, }, @rabbita.none, ) @@ -271,34 +335,110 @@ fn update( Split }, phase: model.phase, + route: model.route, + share_url: model.share_url, + copy_status: model.copy_status, }, @rabbita.none, ) - Submit => { - let generation = model.generation + 1 + Submit => match parse_commit_url(model.input) { Err(message) => ( { input: model.input, - generation, + generation: model.generation + 1, view_mode: model.view_mode, phase: LoadFailed(message), + route: model.route, + share_url: model.share_url, + copy_status: CopyReady, }, @rabbita.none, ) - Ok(reference) => + Ok(reference) => { + let already_at_route = model.route == Some(reference) + let (next, request) = begin_commit_load(model, reference, emit) + let navigation = if already_at_route { + @rabbita.none + } else { + @nav.push_url(playground_share_url(reference)) + } + (next, @rabbita.batch([navigation, request])) + } + } + RouteChanged(url) => + match reference_from_route(url.fragment) { + Err(message) => ( { - input: model.input, - generation, + input: "", + generation: model.generation + 1, view_mode: model.view_mode, - phase: LoadingCommit(reference~, page=1, first=None, files=[]), + phase: LoadFailed(message), + route: None, + share_url: None, + copy_status: CopyReady, }, - commit_request(reference, generation, 1, emit), + @rabbita.none, + ) + Ok(None) => + ( + { + input: "", + generation: model.generation + 1, + view_mode: model.view_mode, + phase: Idle, + route: None, + share_url: None, + copy_status: CopyReady, + }, + @rabbita.none, + ) + Ok(Some(reference)) => + if model.route == Some(reference) { + (model, @rabbita.none) + } else { + begin_commit_load(model, reference, emit) + } + } + CopyShareLink => + match model.share_url { + None => (model, @rabbita.none) + Some(url) => + ( + model, + @clipboard.copy(Text(url), copied=emit(ShareCopied), failed=message => { + emit(ShareCopyFailed(message)) + }), ) } - } + ShareCopied => + ( + { + input: model.input, + generation: model.generation, + view_mode: model.view_mode, + phase: model.phase, + route: model.route, + share_url: model.share_url, + copy_status: Copied, + }, + @rabbita.none, + ) + ShareCopyFailed(message) => + ( + { + input: model.input, + generation: model.generation, + view_mode: model.view_mode, + phase: model.phase, + route: model.route, + share_url: model.share_url, + copy_status: CopyError(message), + }, + @rabbita.none, + ) CommitPageLoaded(generation, page, result) => { if generation != model.generation { return (model, @rabbita.none) diff --git a/playground/main/view.mbt b/playground/main/view.mbt index c466195..d671cda 100644 --- a/playground/main/view.mbt +++ b/playground/main/view.mbt @@ -18,6 +18,16 @@ fn status_label(status : String) -> String { } } +///| +fn status_class(status : String) -> String { + match status { + "added" => "file-status status-added" + "removed" => "file-status status-removed" + "renamed" => "file-status status-renamed" + _ => "file-status" + } +} + ///| fn file_label(file : ApiFile) -> String { match file.previous_filename { @@ -35,6 +45,119 @@ fn source_problem(side : SideLoad) -> String? { } } +///| +fn brand_view() -> @rabbita.Html { + @html.h1(class="brand", [ + @html.span(class="brand-mark", [ + @html.span(class="mark-plus", "+"), + @html.span(class="mark-minus", "−"), + ]), + @html.span("ldiff"), + @html.span(class="brand-suffix", "playground"), + ]) +} + +///| +fn commit_form(model : Model, emit : @rabbita.Emit[Msg]) -> @rabbita.Html { + @html.form(on_submit=emit(Submit), class="commit-form", [ + @html.label(for_="commit-url", class="sr-only", "Public GitHub commit URL"), + @html.div(class="input-row", [ + @html.input( + id="commit-url", + input_type=Url, + value=model.input, + placeholder="https://github.com/owner/repo/commit/sha", + required=true, + on_input=value => emit(InputChanged(value)), + ), + @html.button(type_="submit", class="submit-button", [ + @html.span("View diff"), + @html.span(class="submit-arrow", "→"), + ]), + ]), + ]) +} + +///| +fn route_example() -> @rabbita.Html { + @html.div(class="route-example", [ + @html.code(class="route-old", [ + @html.span(class="route-prefix", "− github"), + @html.span(".com/owner/repo/commit/sha"), + ]), + @html.code(class="route-new", [ + @html.span(class="route-prefix", "+ playground"), + @html.span("/#/owner/repo/commit/sha"), + ]), + ]) +} + +///| +fn landing_details() -> @rabbita.Html { + @html.fragment([ + @html.div(class="public-note", [ + @html.span(class="note-dot", ""), + @html.p( + "Public repositories only. No token is requested, stored, or sent. Anonymous GitHub API limits apply.", + ), + ]), + @html.section(class="supported-links", [ + @html.h2("Supported links"), + @html.ul([ + @html.li([ + @html.span(class="list-arrow", "→"), + @html.code("github.com/owner/repo/commit/sha"), + ]), + @html.li([ + @html.span(class="list-arrow", "→"), + @html.code("github.com/owner/repo/pull/number/changes/sha"), + ]), + ]), + @html.p( + "Opening the generated playground URL restores the same commit automatically, ready to share with a teammate.", + ), + ]), + ]) +} + +///| +fn hero_view( + model : Model, + landing : Bool, + emit : @rabbita.Emit[Msg], +) -> @rabbita.Html { + @html.header( + class=if landing { "hero hero-landing" } else { "hero hero-workspace" }, + [ + @html.div(class="hero-copy", [ + brand_view(), + if landing { + @html.fragment([ + @html.p( + class="lede", + "Review MoonBit changes from a public GitHub commit in a fast, focused diff — then share the exact playground URL with anyone.", + ), + route_example(), + ]) + } else { + @html.p( + class="workspace-caption", + "Public GitHub commits, rendered with ldiff", + ) + }, + ]), + @html.div(class="hero-controls", [ + @html.div(class="input-panel", commit_form(model, emit)), + if landing { + landing_details() + } else { + @html.nothing + }, + ]), + ], + ) +} + ///| #warnings("-alert_xss_vulnerable") fn file_diff_view( @@ -93,7 +216,7 @@ fn file_diff_view( ) } (Loading, _) | (_, Loading) => - @html.p(class="file-message", "Loading file contents…") + @html.p(class="file-message loading-inline", "Loading file contents…") _ => @html.p(class="file-message", "Expand this file to load its diff.") } } @@ -106,52 +229,110 @@ fn file_card( mode : ViewMode, emit : @rabbita.Emit[Msg], ) -> @rabbita.Html { - @html.article(class="file-card", [ - @html.div(class="file-heading", [ - @html.div([ - @html.span(class="file-status", status_label(file.file.status)), - @html.code(class="file-path", file_label(file.file)), - ]), - @html.div(class="file-actions", [ - @html.span( - class="file-stats", - "+\{file.file.additions} −\{file.file.deletions}", - ), - @html.button( - type_="button", - class="secondary compact", - on_click=emit(ToggleFile(generation, index)), - if file.expanded { - "Collapse" - } else { - "Expand" - }, - ), + @html.article( + class=if file.expanded { "file-card expanded" } else { "file-card" }, + [ + @html.div(class="file-heading", [ + @html.div(class="file-identity", [ + @html.span( + class=status_class(file.file.status), + status_label(file.file.status), + ), + @html.code(class="file-path", file_label(file.file)), + ]), + @html.div(class="file-actions", [ + @html.span(class="file-stats", [ + @html.span(class="additions", "+\{file.file.additions}"), + @html.span(class="deletions", "−\{file.file.deletions}"), + ]), + @html.button( + type_="button", + class="secondary compact", + on_click=emit(ToggleFile(generation, index)), + if file.expanded { + "Collapse" + } else { + "Expand" + }, + ), + ]), ]), - ]), - if file.expanded { - file_diff_view(file, generation, index, mode, emit) - } else { - @html.nothing - }, - ]) + if file.expanded { + file_diff_view(file, generation, index, mode, emit) + } else { + @html.nothing + }, + ], + ) +} + +///| +fn copy_button_label(status : CopyStatus) -> String { + match status { + CopyReady => "Copy link" + Copied => "Copied" + CopyError(_) => "Try copy again" + } +} + +///| +fn share_view(model : Model, emit : @rabbita.Emit[Msg]) -> @rabbita.Html { + match model.share_url { + None => @html.nothing + Some(url) => + @html.div(class="share-panel", [ + @html.div(class="share-copy", [ + @html.label(for_="share-url", "Shareable playground URL"), + @html.p("Anyone with this link can open the same public commit."), + ]), + @html.div(class="share-row", [ + @html.input( + id="share-url", + value=url, + read_only=true, + class="share-url", + ), + @html.button( + type_="button", + class=if model.copy_status is Copied { + "copy-button copied" + } else { + "copy-button" + }, + title=match model.copy_status { + CopyError(message) => message + _ => "Copy the shareable playground URL" + }, + on_click=emit(CopyShareLink), + copy_button_label(model.copy_status), + ), + ]), + match model.copy_status { + CopyError(message) => + @html.p(class="copy-error", "Clipboard access failed: \{message}") + _ => @html.nothing + }, + ]) + } } ///| fn commit_view( commit : CommitView, - generation : Int, - mode : ViewMode, + model : Model, emit : @rabbita.Emit[Msg], ) -> @rabbita.Html { let file_views : Array[@rabbita.Html] = [] for index, file in commit.files { - file_views.push(file_card(file, generation, index, mode, emit)) + file_views.push( + file_card(file, model.generation, index, model.view_mode, emit), + ) } @html.section(class="results", [ @html.div(class="commit-card", [ @html.div(class="commit-title", [ - @html.div([ + @html.div(class="commit-identity", [ + @html.p(class="section-label", "Public commit"), @html.a( href=commit.html_url, target=Blank, @@ -170,7 +351,7 @@ fn commit_view( type_="button", class="view-toggle", on_click=emit(ToggleView), - match mode { + match model.view_mode { Split => "Use unified view" Unified => "Use split view" }, @@ -178,11 +359,15 @@ fn commit_view( ]), @html.pre(class="commit-message", commit.message), @html.div(class="summary-grid", [ - @html.span("+\{commit.stats.additions} additions"), - @html.span("−\{commit.stats.deletions} deletions"), + @html.span(class="summary-add", "+\{commit.stats.additions} additions"), + @html.span( + class="summary-del", + "−\{commit.stats.deletions} deletions", + ), @html.span("\{commit.total_files} files processed"), @html.span("\{commit.files.length()} MoonBit files shown"), ]), + share_view(model, emit), ]), if commit.files.length() == 0 { @html.div( @@ -198,16 +383,14 @@ fn commit_view( ///| fn phase_view(model : Model, emit : @rabbita.Emit[Msg]) -> @rabbita.Html { match model.phase { - Idle => - @html.div( - class="empty-state", - "Paste a public GitHub commit URL to render its MoonBit changes.", - ) + Idle => @html.nothing LoadingCommit(page~, ..) => - @html.div( - class="empty-state loading", - "Loading commit metadata (page \{page}, up to 100 files per page)…", - ) + @html.div(class="empty-state loading", [ + @html.span(class="spinner", ""), + @html.span( + "Loading commit metadata (page \{page}, up to 100 files per page)…", + ), + ]) LoadFailed(message) => @html.div(class="empty-state error", [ @html.p(message), @@ -218,46 +401,27 @@ fn phase_view(model : Model, emit : @rabbita.Emit[Msg]) -> @rabbita.Html { "Try again", ), ]) - Showing(commit) => - commit_view(commit, model.generation, model.view_mode, emit) + Showing(commit) => commit_view(commit, model, emit) + } +} + +///| +fn landing_mode(model : Model) -> Bool { + match model.phase { + Idle => true + LoadFailed(_) => model.route is None + _ => false } } ///| fn app_view(model : Model, emit : @rabbita.Emit[Msg]) -> @rabbita.Html { - @html.main_(class="shell", [ - @html.header(class="hero", [ - @html.div(class="hero-copy", [ - @html.p(class="eyebrow", "ldiff playground"), - @html.h1("GitHub Commit Diff for MoonBit"), - @html.p( - class="lede", - "Compare a public commit with its first parent. Only MoonBit source and project files are rendered.", - ), - ]), - @html.div(class="hero-controls", [ - @html.form(on_submit=emit(Submit), class="commit-form", [ - @html.label(for_="commit-url", "GitHub commit URL"), - @html.div(class="input-row", [ - @html.input( - id="commit-url", - input_type=Url, - value=model.input, - placeholder="https://github.com/owner/repo/commit/abcdef1", - required=true, - on_input=value => emit(InputChanged(value)), - ), - @html.button(type_="submit", "Load diff"), - ]), - ]), - @html.p( - class="privacy-note", - "Public repositories only. This page does not accept, store, or send personal access tokens. Anonymous GitHub API limits apply.", - ), - ]), - ]), + let landing = landing_mode(model) + @html.main_(class=if landing { "shell landing" } else { "shell workspace" }, [ + hero_view(model, landing, emit), phase_view(model, emit), @html.footer([ + @html.span("Built with ldiff for MoonBit · "), @html.a( href="https://github.com/moonbit-community/ldiff", target=Blank, diff --git a/playground/public/favicon.svg b/playground/public/favicon.svg new file mode 100644 index 0000000..5f0b90f --- /dev/null +++ b/playground/public/favicon.svg @@ -0,0 +1,4 @@ + diff --git a/playground/public/index.html b/playground/public/index.html index 998e10d..ef0b0cc 100644 --- a/playground/public/index.html +++ b/playground/public/index.html @@ -3,14 +3,17 @@
- -