Plan squashes and rebases by dragging on a proposal's swimlanes 2bfe5ef66b0c
On an editable proposal's board, dropping one change on another asks the hub what the drop could mean. The answer is a squash and/or a rebase, each with its oxy commands and a VS Code link that rewrites the changes where the author's key is. closes #36
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 07:11 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
2bfe5ef66b0c50fe534d11cac8b8a51aa92daa248f27316df8c6e6c01e5f3dea- Depends on
- 5e6d6484c28e 736f6813543d 92574625ee9a 93be9ce4653e b64e2537d702 d23464c0ac60
- Depended on by
- c8684d44d549 cb71e18eb6f9 c96b32865000 129c23effb73 d3e75fb41e61 38833ed78707
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 10 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified assets/hub.js +42 −0
| … | 342 unchanged lines |
||
| 343 | 343 | live.querySelectorAll("input, textarea, button").forEach(function (x) { x.disabled = true; }); |
|
| 344 | 344 | }); |
|
| 345 | 345 | } |
|
| 346 | + | ||
| 347 | + | // Swimlanes of a proposal's bookmark (hub spec §6.7): drag a change onto |
|
| 348 | + | // another to plan a squash or a rebase. The hub only writes the plan; |
|
| 349 | + | // the author's client carries it out and signs it. |
|
| 350 | + | var plan = document.querySelector("[data-plan]"); |
|
| 351 | + | if (plan && window.fetch) { |
|
| 352 | + | var panel = document.querySelector("[data-plan-panel]"), from = null, moved = false; |
|
| 353 | + | plan.querySelectorAll("[data-change]").forEach(function (a) { |
|
| 354 | + | a.style.cursor = "grab"; |
|
| 355 | + | a.addEventListener("pointerdown", function (ev) { from = a.getAttribute("data-change"); moved = false; ev.preventDefault(); }); |
|
| 356 | + | a.addEventListener("pointermove", function () { if (from) moved = true; }); |
|
| 357 | + | a.addEventListener("click", function (ev) { if (moved) ev.preventDefault(); }); |
|
| 358 | + | }); |
|
| 359 | + | document.addEventListener("pointerup", function (ev) { |
|
| 360 | + | var start = from; |
|
| 361 | + | from = null; |
|
| 362 | + | if (!start) return; |
|
| 363 | + | var el = document.elementFromPoint(ev.clientX, ev.clientY), onto = el && el.closest ? el.closest("[data-change]") : null; |
|
| 364 | + | if (!onto || onto.getAttribute("data-change") === start) return; |
|
| 365 | + | var body = new URLSearchParams({ _csrf: plan.getAttribute("data-csrf"), change: start, target: onto.getAttribute("data-change") }); |
|
| 366 | + | fetch(plan.getAttribute("data-plan"), { method: "POST", credentials: "same-origin", body: body }) |
|
| 367 | + | .then(function (r) { return r.json(); }) |
|
| 368 | + | .then(function (d) { |
|
| 369 | + | panel.hidden = false; |
|
| 370 | + | panel.textContent = ""; |
|
| 371 | + | if (d.error) { panel.textContent = d.error; return; } |
|
| 372 | + | d.options.forEach(function (o) { |
|
| 373 | + | var p = document.createElement("p"); |
|
| 374 | + | p.appendChild(document.createTextNode(o.summary + " ")); |
|
| 375 | + | var a = document.createElement("a"); |
|
| 376 | + | a.href = o.vscode; |
|
| 377 | + | a.textContent = "Apply in VS Code"; |
|
| 378 | + | p.appendChild(a); |
|
| 379 | + | var code = document.createElement("pre"); |
|
| 380 | + | code.className = "snippet"; |
|
| 381 | + | code.textContent = o.command; |
|
| 382 | + | panel.appendChild(p); |
|
| 383 | + | panel.appendChild(code); |
|
| 384 | + | }); |
|
| 385 | + | }); |
|
| 386 | + | }); |
|
| 387 | + | } |
|
| 346 | 388 | })(); |
modified crates/hub/src/web/history.rs +149 −1
| … | 136 unchanged lines |
||
| 137 | 137 | target: view::RefTarget, |
|
| 138 | 138 | total: usize, |
|
| 139 | 139 | board: graph::Board, |
|
| 140 | + | /// The open proposal whose source this bookmark is, when the viewer may |
|
| 141 | + | /// rewrite it: cards can then be dragged onto each other (P3b). |
|
| 142 | + | proposal: Option<i64>, |
|
| 140 | 143 | } |
|
| 141 | 144 | ||
| 142 | 145 | /// `GET /{owner}/{name}/lanes/{ref}`: a bookmark's newest changes as |
|
| … | 16 unchanged lines |
||
| 159 | 162 | .await |
|
| 160 | 163 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 161 | 164 | let board = graph::board(&h.entries); |
|
| 162 | - | Ok(Page(LanesPage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), target: h.target, total: h.total, board })) |
|
| 165 | + | let proposal = editable_proposal(&app, &viewer, &row, role, &r).await.map(|p| p.number); |
|
| 166 | + | Ok(Page(LanesPage { |
|
| 167 | + | shell, |
|
| 168 | + | repo: RepoShell::new(&app, &row, "changes").with_role(role), |
|
| 169 | + | target: h.target, |
|
| 170 | + | total: h.total, |
|
| 171 | + | board, |
|
| 172 | + | proposal, |
|
| 173 | + | })) |
|
| 174 | + | } |
|
| 175 | + | ||
| 176 | + | /// The open proposal whose source is `bookmark` of this repository, when |
|
| 177 | + | /// the viewer is its author or may write here: only those bookmarks are |
|
| 178 | + | /// rewritten from the board, and only by their owners' clients. |
|
| 179 | + | async fn editable_proposal( |
|
| 180 | + | app: &Shared, |
|
| 181 | + | viewer: &Viewer, |
|
| 182 | + | row: &crate::db::repos::RepoRow, |
|
| 183 | + | role: crate::auth::Role, |
|
| 184 | + | bookmark: &str, |
|
| 185 | + | ) -> Option<crate::db::proposals::ProposalRow> { |
|
| 186 | + | let me = viewer.account()?; |
|
| 187 | + | let open = crate::db::proposals::list(&app.db, row.id, &["open", "draft"]).await.ok()?; |
|
| 188 | + | open.into_iter().find(|p| { |
|
| 189 | + | p.source_bookmark == bookmark |
|
| 190 | + | && p.source_repo_id.is_none_or(|id| id == row.id) |
|
| 191 | + | && (p.author_id == Some(me.id) || role >= crate::auth::Role::Write) |
|
| 192 | + | }) |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | /// One rewrite the board offers for a drop (hub spec §6.7). |
|
| 196 | + | #[derive(serde::Serialize)] |
|
| 197 | + | pub struct PlanOption { |
|
| 198 | + | /// `squash` or `rebase`. |
|
| 199 | + | kind: &'static str, |
|
| 200 | + | summary: String, |
|
| 201 | + | /// What to run in a checkout of the bookmark. |
|
| 202 | + | command: String, |
|
| 203 | + | /// The same, handed to oxygen-vscode. |
|
| 204 | + | vscode: String, |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | #[derive(Deserialize)] |
|
| 208 | + | pub struct PlanForm { |
|
| 209 | + | #[serde(rename = "_csrf")] |
|
| 210 | + | csrf_token: String, |
|
| 211 | + | /// The dragged change and the one it was dropped on, full hashes. |
|
| 212 | + | change: String, |
|
| 213 | + | target: String, |
|
| 214 | + | } |
|
| 215 | + | ||
| 216 | + | /// The rewrites a drop may mean, for a proposal bookmark (pure, tested): |
|
| 217 | + | /// squashing `c` into `t` when `c` needs nothing `t` lacks, rebasing `c` |
|
| 218 | + | /// onto `t` when `t` does not already have it. Reordering independent |
|
| 219 | + | /// changes means nothing in Oxygen, so a drop never offers it. |
|
| 220 | + | pub fn plan_options(state: &oxy_core::state::State, c: &oxy_core::hash::ChangeHash, t: &oxy_core::hash::ChangeHash) -> Vec<&'static str> { |
|
| 221 | + | use oxy_core::graph::DepOracle; |
|
| 222 | + | if c == t || !state.contains(c) || !state.contains(t) || state.depends_on(t, c) { |
|
| 223 | + | return vec![]; |
|
| 224 | + | } |
|
| 225 | + | let mut out = Vec::new(); |
|
| 226 | + | let closure = state.closure(t); |
|
| 227 | + | let deps_ok = state.get(c).is_some_and(|m| m.deps.iter().all(|d| d == t || closure.contains(d))); |
|
| 228 | + | if deps_ok { |
|
| 229 | + | out.push("squash"); |
|
| 230 | + | } |
|
| 231 | + | if !state.get(c).is_some_and(|m| m.deps.contains(t)) { |
|
| 232 | + | out.push("rebase"); |
|
| 233 | + | } |
|
| 234 | + | out |
|
| 235 | + | } |
|
| 236 | + | ||
| 237 | + | /// `POST /{owner}/{name}/lanes/{bookmark}/plan`: what dropping one card on |
|
| 238 | + | /// another would do, as commands for the author's client to run and sign. |
|
| 239 | + | /// The hub rewrites nothing itself. |
|
| 240 | + | pub async fn plan( |
|
| 241 | + | State(app): State<Shared>, |
|
| 242 | + | viewer: Viewer, |
|
| 243 | + | Path((owner, name, r)): Path<(String, String, String)>, |
|
| 244 | + | axum::extract::Form(f): axum::extract::Form<PlanForm>, |
|
| 245 | + | ) -> Result<Response, Error> { |
|
| 246 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/lanes/{r}"), &viewer); |
|
| 247 | + | super::settings::csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 248 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 249 | + | let problem = |msg: &str| (axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": msg }))).into_response(); |
|
| 250 | + | let Some(proposal) = editable_proposal(&app, &viewer, &row, role, &r).await else { |
|
| 251 | + | return Ok(problem("Changes are rearranged only on the bookmark of an open proposal you may change.")); |
|
| 252 | + | }; |
|
| 253 | + | let (Some(c), Some(t)) = (oxy_core::hash::ChangeHash::from_hex(&f.change), oxy_core::hash::ChangeHash::from_hex(&f.target)) else { |
|
| 254 | + | return Ok(problem("Name two changes.")); |
|
| 255 | + | }; |
|
| 256 | + | let (spec, target_bookmark) = (r.clone(), proposal.target.clone()); |
|
| 257 | + | let found = app |
|
| 258 | + | .repos |
|
| 259 | + | .read(&repo, move |repo, cache| { |
|
| 260 | + | let here = view::resolve(repo, cache, &spec)?; |
|
| 261 | + | let loaded = cache.load(repo, &here.heads)?; |
|
| 262 | + | // What has landed on the target is history, not a draft. |
|
| 263 | + | let landed = match repo.view()?.bookmarks.get(&target_bookmark) { |
|
| 264 | + | Some(st) => { |
|
| 265 | + | let heads = repo.store().get_state(st)?.heads; |
|
| 266 | + | let target_state = cache.load(repo, &heads)?; |
|
| 267 | + | target_state.state.contains(&c) || target_state.state.contains(&t) |
|
| 268 | + | } |
|
| 269 | + | None => false, |
|
| 270 | + | }; |
|
| 271 | + | let meta = |h: &oxy_core::hash::ChangeHash| loaded.state.get(h).map(|m| m.change_id.short(8)); |
|
| 272 | + | Ok((plan_options(&loaded.state, &c, &t), landed, meta(&c), meta(&t))) |
|
| 273 | + | }) |
|
| 274 | + | .await |
|
| 275 | + | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 276 | + | let (kinds, landed, Some(cid), Some(tid)) = found else { |
|
| 277 | + | return Ok(problem("Both changes must be on this bookmark.")); |
|
| 278 | + | }; |
|
| 279 | + | if landed { |
|
| 280 | + | return Ok(problem(&format!("That change is already on {}; only the proposal's own changes are rearranged.", proposal.target))); |
|
| 281 | + | } |
|
| 282 | + | if kinds.is_empty() { |
|
| 283 | + | return Ok(problem("That drop means nothing here: the change is already part of the other, or they are the same.")); |
|
| 284 | + | } |
|
| 285 | + | let base = format!("{}/{owner}/{name}", app.config.base_url().trim_end_matches('/')); |
|
| 286 | + | let enc = super::settings::urlencode; |
|
| 287 | + | let options: Vec<PlanOption> = kinds |
|
| 288 | + | .into_iter() |
|
| 289 | + | .map(|kind| { |
|
| 290 | + | let (summary, command) = match kind { |
|
| 291 | + | "squash" => ( |
|
| 292 | + | format!("Squash {cid} into {tid}: one change instead of two."), |
|
| 293 | + | format!("oxy squash #{} --into #{}", c.to_hex(), t.to_hex()), |
|
| 294 | + | ), |
|
| 295 | + | _ => ( |
|
| 296 | + | format!("Rebase {cid} onto {tid}: {cid} comes to depend on {tid}."), |
|
| 297 | + | format!("oxy rebase #{} --onto #{}", c.to_hex(), t.to_hex()), |
|
| 298 | + | ), |
|
| 299 | + | }; |
|
| 300 | + | let vscode = format!( |
|
| 301 | + | "vscode://oxygen.oxygen-vscode/plan?url={}&bookmark={}&kind={kind}&change={}&target={}", |
|
| 302 | + | enc(&base), |
|
| 303 | + | enc(&r), |
|
| 304 | + | c.to_hex(), |
|
| 305 | + | t.to_hex() |
|
| 306 | + | ); |
|
| 307 | + | PlanOption { kind, summary, command: format!("oxy bookmark switch {r} && {command} && oxy push --force"), vscode } |
|
| 308 | + | }) |
|
| 309 | + | .collect(); |
|
| 310 | + | Ok(axum::Json(serde_json::json!({ "proposal": proposal.number, "options": options })).into_response()) |
|
| 163 | 311 | } |
|
| 164 | 312 | ||
| 165 | 313 | /// A file's diff, rendered. |
|
| … | 201 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 95 unchanged lines |
||
| 96 | 96 | .route("/{owner}/{name}/file/{r}/{*path}", get(repo::file)) |
|
| 97 | 97 | .route("/{owner}/{name}/function/{r}/{*path}", get(history::function)) |
|
| 98 | 98 | .route("/{owner}/{name}/lanes/{r}", get(history::lanes)) |
|
| 99 | + | .route("/{owner}/{name}/lanes/{r}/plan", post(history::plan)) |
|
| 99 | 100 | .route("/{owner}/{name}/raw/{r}/{*path}", get(repo::raw_file)) |
|
| 100 | 101 | .route("/{owner}/{name}/last/{r}", get(repo::last_changes)) |
|
| 101 | 102 | .route("/{owner}/{name}/changes/{r}", get(history::history)) |
|
| … | 487 unchanged lines |
modified crates/hub/tests/api.rs +75 −0
| … | 968 unchanged lines |
||
| 969 | 969 | assert_eq!(board.matches("<path d=\"M").count(), 1, "grow a depends on add a only"); |
|
| 970 | 970 | assert!(r.body.contains("grow a") && r.body.contains("/andy/demo/change/")); |
|
| 971 | 971 | } |
|
| 972 | + | ||
| 973 | + | /// Planning rewrites from a proposal's swimlanes (hub spec §6.7): the hub |
|
| 974 | + | /// offers only what makes sense and rewrites nothing itself. |
|
| 975 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 976 | + | async fn a_proposal_board_plans_squashes_and_rebases() { |
|
| 977 | + | let h = hub().await; |
|
| 978 | + | let app = &h.app; |
|
| 979 | + | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 980 | + | .await |
|
| 981 | + | .cookie |
|
| 982 | + | .unwrap(); |
|
| 983 | + | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 984 | + | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 985 | + | let url = format!("{}/andy/demo", h.base); |
|
| 986 | + | let work = tempfile::tempdir().unwrap(); |
|
| 987 | + | let w = work.path().to_path_buf(); |
|
| 988 | + | let (base, first, second, apart) = tokio::task::spawn_blocking(move || { |
|
| 989 | + | let mut c = Repo::init(&w).unwrap(); |
|
| 990 | + | c.config.set("remote.origin.url", &url).unwrap(); |
|
| 991 | + | c.config.set("remote.origin.token", "site").unwrap(); |
|
| 992 | + | c.save_config().unwrap(); |
|
| 993 | + | write(&w, "a.txt", b"a\n"); |
|
| 994 | + | let base = c.commit("base", &[]).unwrap(); |
|
| 995 | + | assert!(c.push("origin", false).unwrap()[0].ok); |
|
| 996 | + | c.bookmark_create("feature", None).unwrap(); |
|
| 997 | + | c.bookmark_switch("feature", false).unwrap(); |
|
| 998 | + | write(&w, "a.txt", b"a\nfirst\n"); |
|
| 999 | + | let first = c.commit("first", &[]).unwrap(); |
|
| 1000 | + | write(&w, "a.txt", b"a\nfirst\nsecond\n"); |
|
| 1001 | + | let second = c.commit("second", &[]).unwrap(); |
|
| 1002 | + | write(&w, "b.txt", b"apart\n"); |
|
| 1003 | + | let apart = c.commit("apart", &[]).unwrap(); |
|
| 1004 | + | assert!(c.push_bookmark("origin", "feature", false).unwrap()[0].ok); |
|
| 1005 | + | (base.to_hex(), first.to_hex(), second.to_hex(), apart.to_hex()) |
|
| 1006 | + | }) |
|
| 1007 | + | .await |
|
| 1008 | + | .unwrap(); |
|
| 1009 | + | // No proposal yet: the board is read-only. |
|
| 1010 | + | let board = call(app, "GET", "/andy/demo/lanes/feature", None, Some(&andy), None, None).await.body; |
|
| 1011 | + | assert!(!board.contains("data-plan=")); |
|
| 1012 | + | let (st, _) = api( |
|
| 1013 | + | app, |
|
| 1014 | + | "POST", |
|
| 1015 | + | "/api/v1/repos/andy/demo/proposals", |
|
| 1016 | + | Some("site"), |
|
| 1017 | + | Some(r#"{"title":"Feature","target":"main","source":"feature"}"#), |
|
| 1018 | + | ) |
|
| 1019 | + | .await; |
|
| 1020 | + | assert!(st.is_success(), "{st}"); |
|
| 1021 | + | let board = call(app, "GET", "/andy/demo/lanes/feature", None, Some(&andy), None, None).await.body; |
|
| 1022 | + | assert!(board.contains("data-plan=\"/andy/demo/lanes/feature/plan\""), "{board}"); |
|
| 1023 | + | let csrf = csrf_of(&board); |
|
| 1024 | + | let plan = |c: &str, t: &str| { |
|
| 1025 | + | let (app, andy, csrf) = (app.clone(), andy.clone(), csrf.clone()); |
|
| 1026 | + | let form = format!("_csrf={csrf}&change={c}&target={t}"); |
|
| 1027 | + | async move { |
|
| 1028 | + | let r = call(&app, "POST", "/andy/demo/lanes/feature/plan", None, Some(&andy), None, Some(&form)).await; |
|
| 1029 | + | serde_json::from_str::<serde_json::Value>(&r.body).unwrap() |
|
| 1030 | + | } |
|
| 1031 | + | }; |
|
| 1032 | + | let kinds = |v: &serde_json::Value| { |
|
| 1033 | + | v["options"].as_array().map(|a| a.iter().map(|o| o["kind"].as_str().unwrap().to_string()).collect::<Vec<_>>()).unwrap_or_default() |
|
| 1034 | + | }; |
|
| 1035 | + | // second already depends on first: squash, no rebase. |
|
| 1036 | + | let v = plan(&second, &first).await; |
|
| 1037 | + | assert_eq!(kinds(&v), ["squash"], "{v}"); |
|
| 1038 | + | assert!(v["options"][0]["command"].as_str().unwrap().contains(&format!("oxy squash #{second} --into #{first}"))); |
|
| 1039 | + | assert!(v["options"][0]["vscode"].as_str().unwrap().starts_with("vscode://oxygen.oxygen-vscode/plan?")); |
|
| 1040 | + | // apart is independent of second: either. |
|
| 1041 | + | assert_eq!(kinds(&plan(&apart, &second).await), ["squash", "rebase"]); |
|
| 1042 | + | // first under second: nothing (second depends on first). |
|
| 1043 | + | assert!(plan(&first, &second).await["error"].is_string()); |
|
| 1044 | + | // What has landed on main is not rearranged. |
|
| 1045 | + | assert!(plan(&second, &base).await["error"].as_str().unwrap().contains("already on main")); |
|
| 1046 | + | } |
modified docs/spec.md +2 −0
| … | 293 unchanged lines |
||
| 294 | 294 | ||
| 295 | 295 | **Swimlanes.** `/{owner}/{name}/lanes/{ref}` draws a bookmark's newest changes (up to 60) as a board: each lane of the shared lane layout (`oxy_core::lanes`, as `oxy log` and the list view use) is a row, time runs left to right, every change is a card linking to its page, and an edge joins a change to each change of the board it depends on. Independent work stays in separate lanes and there are no merge nodes, since Oxygen has none. The history page links to it. |
|
| 296 | 296 | ||
| 297 | + | **Planning on the board.** When the bookmark is the source of an open or draft proposal that the viewer wrote or can write to, the board is editable. Dragging a change onto another asks `POST /{owner}/{name}/lanes/{ref}/plan` (form `change`, `target`) what the drop could mean. The answer is JSON: a **squash** (fold the change into the target) when every dependency of the change is the target or already beneath it, and a **rebase** (make the change depend on the target) unless it already does. Nothing is offered when the target depends on the change, or when either is already on the proposal's target bookmark. The hub does not rewrite the changes itself: rewritten changes must be signed by their author, so each option carries the `oxy` commands to run and a `vscode://oxygen.oxygen-vscode/plan?…` link that does the same in the VS Code extension, then force-pushes the bookmark. |
|
| 298 | + | ||
| 297 | 299 | ### 6.8 Change page |
|
| 298 | 300 | ||
| 299 | 301 | Header: full hash, message, author identity (linked account or KeyId), timestamp, signature status with the policy rule that admitted it (or "unsigned", or "invalid"), seals covering it, the bookmarks and tags containing it. Body: dependencies and dependents as chips, the change's edits as a diff (unified or split, per-user preference), atom counts, and the raw change (`oxy show --json`) behind a details block. |
|
| … | 289 unchanged lines |
modified templates/lanes.html +4 −2
| … | 7 unchanged lines |
||
| 8 | 8 | <div class="head"><h1>Swimlanes <small>{{ target.label }} · {{ board.cards.len() }}{% if total > board.cards.len() %} newest of {{ total }}{% endif %} changes in {{ board.lanes }} lane{% if board.lanes != 1 %}s{% endif %}</small></h1> |
|
| 9 | 9 | <p class="small mute">Changes that do not depend on each other run in separate lanes, oldest on the left; an edge joins a change to what it depends on. There are no merge nodes: parallel work simply stays parallel. <a href="{{ repo.url }}/changes/{{ target.label }}">list view</a></p></div> |
|
| 10 | 10 | {% if board.cards.is_empty() %}<p class="empty">No changes.</p>{% else %} |
|
| 11 | - | <div class="lanes-board" style="overflow-x: auto"> |
|
| 11 | + | {% if let Some(n) = proposal %}<p class="small">This is the bookmark of proposal <a href="{{ repo.url }}/proposal/{{ n }}">#{{ n }}</a>: drag a change onto another to plan a squash or a rebase. The hub only writes the plan; your oxy or oxygen-vscode carries it out and signs it.</p> |
|
| 12 | + | <div class="panel" data-plan-panel hidden></div>{% endif %} |
|
| 13 | + | <div class="lanes-board" style="overflow-x: auto"{% if proposal.is_some() %} data-plan="{{ repo.url }}/lanes/{{ target.label }}/plan" data-csrf="{{ shell.csrf }}"{% endif %}> |
|
| 12 | 14 | <svg width="{{ board.width }}" height="{{ board.height }}" viewBox="0 0 {{ board.width }} {{ board.height }}" role="img" aria-label="Swimlanes of {{ target.label }}"> |
|
| 13 | 15 | {% for e in board.edges %}<path d="{{ e }}" class="lane" fill="none"/>{% endfor %} |
|
| 14 | 16 | {% for c in board.cards %} |
|
| 15 | - | <a href="{{ repo.url }}/change/{{ c.hash }}"> |
|
| 17 | + | <a href="{{ repo.url }}/change/{{ c.hash }}" data-change="{{ c.hash }}"> |
|
| 16 | 18 | <title>{{ c.short }} {{ c.title }}</title> |
|
| 17 | 19 | <rect x="{{ c.x }}" y="{{ c.y }}" width="{{ crate::render::graph::CARD_W }}" height="{{ crate::render::graph::CARD_H }}" rx="6" class="node{% if c.head %} head{% endif %}"/> |
|
| 18 | 20 | <text x="{{ c.x + 8 }}" y="{{ c.y + 14 }}" class="small">{{ c.short }}</text> |
|
| … | 7 unchanged lines |