Swimlanes: a bookmark's changes as lanes of independent work 92574625ee9a
/{owner}/{name}/lanes/{ref} draws the newest changes (up to 60) as a
board: each lane of the shared lane layout 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; there are no merge nodes, as Oxygen has none. The
history page links to it.
closes #35
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 06:55 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
92574625ee9a138389317f97fa256f8decc8fe461c153b7de9fbb45daeed47d4- Depends on
- 65be9f9c3074 6ae41081ccd3 736f6813543d 93be9ce4653e b64e2537d702 d23464c0ac60
- Depended on by
- 166f6e9bf4f1 c8684d44d549 8933faff2043 c96b32865000 129c23effb73 d3e75fb41e61 2bfe5ef66b0c
- 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
- 8 atoms, 1 tree operation, 8 files
Browse the tree at this change
added templates/lanes.html +25 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Swimlanes of {{ target.label }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "changes") %}{% endblock %} |
|
| 6 | + | {% block mainclass %} class="wide"{% endblock %} |
|
| 7 | + | {% block content %} |
|
| 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 | + | <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 | + | {% if board.cards.is_empty() %}<p class="empty">No changes.</p>{% else %} |
|
| 11 | + | <div class="lanes-board" style="overflow-x: auto"> |
|
| 12 | + | <svg width="{{ board.width }}" height="{{ board.height }}" viewBox="0 0 {{ board.width }} {{ board.height }}" role="img" aria-label="Swimlanes of {{ target.label }}"> |
|
| 13 | + | {% for e in board.edges %}<path d="{{ e }}" class="lane" fill="none"/>{% endfor %} |
|
| 14 | + | {% for c in board.cards %} |
|
| 15 | + | <a href="{{ repo.url }}/change/{{ c.hash }}"> |
|
| 16 | + | <title>{{ c.short }} {{ c.title }}</title> |
|
| 17 | + | <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 | + | <text x="{{ c.x + 8 }}" y="{{ c.y + 14 }}" class="small">{{ c.short }}</text> |
|
| 19 | + | <text x="{{ c.x + 8 }}" y="{{ c.y + 28 }}" class="small">{{ c.title }}</text> |
|
| 20 | + | </a> |
|
| 21 | + | {% endfor %} |
|
| 22 | + | </svg> |
|
| 23 | + | </div> |
|
| 24 | + | {% endif %} |
|
| 25 | + | {% endblock %} |
modified crates/hub/src/render/graph.rs +107 −0
| … | 9 unchanged lines |
||
| 10 | 10 | changes.iter().map(|(h, d)| (parse(h), d.iter().map(|x| parse(x)).collect())).collect(); |
|
| 11 | 11 | oxy_core::lanes::layout(&input) |
|
| 12 | 12 | } |
|
| 13 | + | ||
| 14 | + | /// A card on the swimlane board (hub spec §6.7): one change, placed by its |
|
| 15 | + | /// lane (row) and its age (column, oldest on the left). |
|
| 16 | + | #[derive(Clone, Debug, PartialEq)] |
|
| 17 | + | pub struct Card { |
|
| 18 | + | pub hash: String, |
|
| 19 | + | pub short: String, |
|
| 20 | + | pub title: String, |
|
| 21 | + | pub x: usize, |
|
| 22 | + | pub y: usize, |
|
| 23 | + | pub head: bool, |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// The swimlane board: cards and the dependency edges between them. |
|
| 27 | + | #[derive(Clone, Debug, PartialEq)] |
|
| 28 | + | pub struct Board { |
|
| 29 | + | pub cards: Vec<Card>, |
|
| 30 | + | /// SVG path data, from a change to each change of the board it depends on. |
|
| 31 | + | pub edges: Vec<String>, |
|
| 32 | + | pub width: usize, |
|
| 33 | + | pub height: usize, |
|
| 34 | + | pub lanes: usize, |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | pub const CARD_W: usize = 150; |
|
| 38 | + | pub const CARD_H: usize = 34; |
|
| 39 | + | const GAP_X: usize = 40; |
|
| 40 | + | const GAP_Y: usize = 22; |
|
| 41 | + | ||
| 42 | + | /// Lay `changes` (newest first, as for [`layout`]) out as swimlanes: each |
|
| 43 | + | /// lane a row, time running left to right. Independent work sits in |
|
| 44 | + | /// separate lanes; there are no merge nodes, only dependency edges. |
|
| 45 | + | pub fn board(changes: &[crate::repos::view::ChangeView]) -> Board { |
|
| 46 | + | let input: Vec<(String, Vec<String>)> = changes.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect(); |
|
| 47 | + | let rows = layout(&input); |
|
| 48 | + | let n = changes.len(); |
|
| 49 | + | let pos: std::collections::HashMap<&str, (usize, usize)> = changes |
|
| 50 | + | .iter() |
|
| 51 | + | .zip(&rows) |
|
| 52 | + | .enumerate() |
|
| 53 | + | .map(|(i, (c, r))| (c.hash.as_str(), ((n - 1 - i) * (CARD_W + GAP_X) + 10, r.lane * (CARD_H + GAP_Y) + 10))) |
|
| 54 | + | .collect(); |
|
| 55 | + | let cards = changes |
|
| 56 | + | .iter() |
|
| 57 | + | .map(|c| { |
|
| 58 | + | let (x, y) = pos[c.hash.as_str()]; |
|
| 59 | + | let mut title: String = c.title.chars().take(22).collect(); |
|
| 60 | + | if c.title.chars().count() > 22 { |
|
| 61 | + | title.push('…'); |
|
| 62 | + | } |
|
| 63 | + | Card { hash: c.hash.clone(), short: c.short.clone(), title, x, y, head: c.head } |
|
| 64 | + | }) |
|
| 65 | + | .collect(); |
|
| 66 | + | let mut edges = Vec::new(); |
|
| 67 | + | for c in changes { |
|
| 68 | + | let (cx, cy) = pos[c.hash.as_str()]; |
|
| 69 | + | for d in &c.deps { |
|
| 70 | + | if let Some(&(dx, dy)) = pos.get(d.as_str()) { |
|
| 71 | + | // From the dependency's right edge to the change's left edge. |
|
| 72 | + | let (x1, y1, x2, y2) = (dx + CARD_W, dy + CARD_H / 2, cx, cy + CARD_H / 2); |
|
| 73 | + | let mid = (x1 + x2) / 2; |
|
| 74 | + | edges.push(format!("M{x1} {y1} C{mid} {y1} {mid} {y2} {x2} {y2}")); |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | let lanes = width(&rows).max(1); |
|
| 79 | + | Board { cards, edges, width: n.max(1) * (CARD_W + GAP_X) + 20, height: lanes * (CARD_H + GAP_Y) + 20, lanes } |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | #[cfg(test)] |
|
| 83 | + | mod board_tests { |
|
| 84 | + | use super::*; |
|
| 85 | + | use crate::repos::view::{AuthorView, ChangeView, SigView}; |
|
| 86 | + | ||
| 87 | + | fn change(hash: char, title: &str, deps: &[char]) -> ChangeView { |
|
| 88 | + | let h = |c: char| std::iter::repeat_n(c, 64).collect::<String>(); |
|
| 89 | + | ChangeView { |
|
| 90 | + | hash: h(hash), |
|
| 91 | + | short: h(hash)[..8].to_string(), |
|
| 92 | + | title: title.into(), |
|
| 93 | + | body: String::new(), |
|
| 94 | + | author: AuthorView { name: String::new(), email: String::new(), key: String::new(), unknown: true, account: None }, |
|
| 95 | + | when_ms: 0, |
|
| 96 | + | deps: deps.iter().map(|d| h(*d)).collect(), |
|
| 97 | + | head: false, |
|
| 98 | + | resolution: false, |
|
| 99 | + | signature: SigView::Unsigned, |
|
| 100 | + | labels: vec![], |
|
| 101 | + | } |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | #[test] |
|
| 105 | + | fn independent_work_gets_its_own_lane() { |
|
| 106 | + | // Newest first: c depends on a; b is independent of both. |
|
| 107 | + | let changes = vec![change('c', "c on a", &['a']), change('b', "b alone", &[]), change('a', "base", &[])]; |
|
| 108 | + | let b = board(&changes); |
|
| 109 | + | assert_eq!(b.cards.len(), 3); |
|
| 110 | + | let card = |h: char| b.cards.iter().find(|c| c.hash.starts_with(h)).unwrap().clone(); |
|
| 111 | + | assert!(card('a').x < card('b').x && card('b').x < card('c').x, "oldest on the left"); |
|
| 112 | + | assert_eq!(card('a').y, card('c').y, "a chain stays in one lane"); |
|
| 113 | + | assert_ne!(card('b').y, card('a').y, "independent work in its own lane"); |
|
| 114 | + | assert_eq!(b.edges.len(), 1, "one dependency, no merge nodes"); |
|
| 115 | + | assert!(b.lanes >= 2); |
|
| 116 | + | let long = board(&[change('d', "a title far longer than the card can hold", &[])]); |
|
| 117 | + | assert!(long.cards[0].title.ends_with('…') && long.cards[0].title.chars().count() == 23); |
|
| 118 | + | } |
|
| 119 | + | } |
modified crates/hub/src/web/history.rs +36 −0
| … | 125 unchanged lines |
||
| 126 | 126 | })) |
|
| 127 | 127 | } |
|
| 128 | 128 | ||
| 129 | + | /// Changes a swimlane board shows at most. |
|
| 130 | + | const BOARD: usize = 60; |
|
| 131 | + | ||
| 132 | + | #[derive(Template)] |
|
| 133 | + | #[template(path = "lanes.html")] |
|
| 134 | + | pub struct LanesPage { |
|
| 135 | + | shell: Shell, |
|
| 136 | + | repo: RepoShell, |
|
| 137 | + | target: view::RefTarget, |
|
| 138 | + | total: usize, |
|
| 139 | + | board: graph::Board, |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | /// `GET /{owner}/{name}/lanes/{ref}`: a bookmark's newest changes as |
|
| 143 | + | /// swimlanes (hub spec §6.7): independent work in separate lanes, time |
|
| 144 | + | /// left to right, dependencies as edges and no merge nodes. |
|
| 145 | + | pub async fn lanes( |
|
| 146 | + | State(app): State<Shared>, |
|
| 147 | + | viewer: Viewer, |
|
| 148 | + | Path((owner, name, r)): Path<(String, String, String)>, |
|
| 149 | + | ) -> Result<Page<LanesPage>, Error> { |
|
| 150 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/lanes/{r}"), &viewer); |
|
| 151 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 152 | + | let spec = r.clone(); |
|
| 153 | + | let h = app |
|
| 154 | + | .repos |
|
| 155 | + | .read(&repo, move |repo, cache| { |
|
| 156 | + | let target = view::resolve(repo, cache, &spec)?; |
|
| 157 | + | view::history(repo, cache, target, None, 0, BOARD) |
|
| 158 | + | }) |
|
| 159 | + | .await |
|
| 160 | + | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 161 | + | 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 })) |
|
| 163 | + | } |
|
| 164 | + | ||
| 129 | 165 | /// A file's diff, rendered. |
|
| 130 | 166 | pub struct FileRows { |
|
| 131 | 167 | pub path: String, |
|
| … | 199 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 94 unchanged lines |
||
| 95 | 95 | .route("/{owner}/{name}/tree/{r}/{*path}", get(repo::tree)) |
|
| 96 | 96 | .route("/{owner}/{name}/file/{r}/{*path}", get(repo::file)) |
|
| 97 | 97 | .route("/{owner}/{name}/function/{r}/{*path}", get(history::function)) |
|
| 98 | + | .route("/{owner}/{name}/lanes/{r}", get(history::lanes)) |
|
| 98 | 99 | .route("/{owner}/{name}/raw/{r}/{*path}", get(repo::raw_file)) |
|
| 99 | 100 | .route("/{owner}/{name}/last/{r}", get(repo::last_changes)) |
|
| 100 | 101 | .route("/{owner}/{name}/changes/{r}", get(history::history)) |
|
| … | 486 unchanged lines |
modified crates/hub/tests/api.rs +43 −0
| … | 925 unchanged lines |
||
| 926 | 926 | let bad = call(app, "GET", "/andy/demo/function/main/src/lib.rs?start=4&end=99", None, None, None, None).await; |
|
| 927 | 927 | assert_ne!(bad.status, StatusCode::OK); |
|
| 928 | 928 | } |
|
| 929 | + | ||
| 930 | + | /// Swimlanes (hub spec §6.7): independent changes in their own lanes, |
|
| 931 | + | /// dependency edges, linked cards. |
|
| 932 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 933 | + | async fn a_bookmark_shows_as_swimlanes() { |
|
| 934 | + | let h = hub().await; |
|
| 935 | + | let app = &h.app; |
|
| 936 | + | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 937 | + | .await |
|
| 938 | + | .cookie |
|
| 939 | + | .unwrap(); |
|
| 940 | + | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 941 | + | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 942 | + | let url = format!("{}/andy/demo", h.base); |
|
| 943 | + | let work = tempfile::tempdir().unwrap(); |
|
| 944 | + | let w = work.path().to_path_buf(); |
|
| 945 | + | tokio::task::spawn_blocking(move || { |
|
| 946 | + | let mut c = Repo::init(&w).unwrap(); |
|
| 947 | + | c.config.set("remote.origin.url", &url).unwrap(); |
|
| 948 | + | c.config.set("remote.origin.token", "site").unwrap(); |
|
| 949 | + | c.save_config().unwrap(); |
|
| 950 | + | // Two files edited independently: two lanes. |
|
| 951 | + | write(&w, "a.txt", b"a\n"); |
|
| 952 | + | c.commit("add a", &[]).unwrap(); |
|
| 953 | + | write(&w, "b.txt", b"b\n"); |
|
| 954 | + | c.commit("add b", &[]).unwrap(); |
|
| 955 | + | write(&w, "a.txt", b"a\na2\n"); |
|
| 956 | + | c.commit("grow a", &[]).unwrap(); |
|
| 957 | + | assert!(c.push("origin", false).unwrap()[0].ok); |
|
| 958 | + | }) |
|
| 959 | + | .await |
|
| 960 | + | .unwrap(); |
|
| 961 | + | let hist = call(app, "GET", "/andy/demo/changes/main", None, None, None, None).await.body; |
|
| 962 | + | assert!(hist.contains("/andy/demo/lanes/main"), "the history page links the board"); |
|
| 963 | + | let r = call(app, "GET", "/andy/demo/lanes/main", None, None, None, None).await; |
|
| 964 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 965 | + | assert!(r.body.contains("3 changes in 2 lanes"), "{}", r.body); |
|
| 966 | + | let start = r.body.find("<div class=\"lanes-board\"").unwrap(); |
|
| 967 | + | let board = &r.body[start..start + r.body[start..].find("</svg>").unwrap()]; |
|
| 968 | + | assert_eq!(board.matches("<rect ").count(), 3); |
|
| 969 | + | assert_eq!(board.matches("<path d=\"M").count(), 1, "grow a depends on add a only"); |
|
| 970 | + | assert!(r.body.contains("grow a") && r.body.contains("/andy/demo/change/")); |
|
| 971 | + | } |
modified docs/spec.md +2 −0
| … | 291 unchanged lines |
||
| 292 | 292 | ||
| 293 | 293 | `?path=` gives the history of one path. |
|
| 294 | 294 | ||
| 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 | + | ||
| 295 | 297 | ### 6.8 Change page |
|
| 296 | 298 | ||
| 297 | 299 | 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/history.html +1 −0
| … | 4 unchanged lines |
||
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "changes") %}{% endblock %} |
|
| 6 | 6 | {% block content %} |
|
| 7 | 7 | <div class="head"><h1>Changes <small>{% if h.target.kind == crate::repos::view::RefKind::Change %}up to # {{ h.target.short_label() }}{% else %}on {{ h.target.label }}{% endif %}{% if let Some(p) = h.path %} touching {{ p }}{% endif %} · {{ h.total }}</small></h1></div> |
|
| 8 | + | <p class="small"><a href="{{ repo.url }}/lanes/{{ h.target.label }}">See as swimlanes</a> <span class="mute">(independent work side by side)</span></p> |
|
| 8 | 9 | {% if h.target.heads.len() > 1 %}<div class="strip warn">{{ h.target.label }} has {{ h.target.heads.len() }} heads. The graph shows their union; heads are marked.</div>{% endif %} |
|
| 9 | 10 | {% if h.entries.is_empty() %}<p class="empty">No changes.</p>{% else %} |
|
| 10 | 11 | <div class="hist" style="--lanes: {{ lanes }}"> |
|
| … | 20 unchanged lines |
modified templates/style.css +6 −0
| … | 231 unchanged lines |
||
| 232 | 232 | .node { fill: var(--acc-2); stroke: var(--acc); stroke-width: 1.5; } |
|
| 233 | 233 | .node.head { fill: var(--now); stroke: var(--ink); stroke-width: 1.5; } |
|
| 234 | 234 | .node.res { fill: var(--ok); } |
|
| 235 | + | /* Swimlanes (hub spec §6.7): cards on lanes, dependency edges between them. */ |
|
| 236 | + | .lanes-board { border: 1px solid var(--line); background: var(--panel); padding: 6px; } |
|
| 237 | + | .lanes-board rect.node { fill: var(--soft); stroke: var(--acc); } |
|
| 238 | + | .lanes-board rect.node.head { stroke: var(--now); stroke-width: 2; } |
|
| 239 | + | .lanes-board a:hover rect.node { fill: var(--line); } |
|
| 240 | + | .lanes-board text { fill: var(--ink); font-size: 11px; } |
|
| 235 | 241 | .chg { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 8px 12px; flex: 1; min-width: 0; } |
|
| 236 | 242 | .chg + .chg, .chg:only-child { border-bottom: 1px solid var(--line); } |
|
| 237 | 243 | .hrow .chg { border-bottom: 0; } |
|
| … | 284 unchanged lines |