OXYGENHUB

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