Resolve a binary file's conflicting versions by keeping one 9e5ef6c23006
A binary file replaced two ways (a replace conflict) now has the same resolver page as a name conflict: its versions, each labelled with the change that wrote it, and a choice of which to keep. Writing the kept version at the path makes a resolution change that replaces every version with it (oxygen#21). The conflicts page links replace conflicts to it. Pinned to the oxygen that settles them. refs andymccall/oxygen#21
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 05:27 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
9e5ef6c2300693dafd5abe6026587fe4c46b9ca5d5134202cbaaa32d128d7396- Depends on
- 08057ce15e77 145306044896 16cb2b3aa947 380996db84e5 93be9ce4653e c5f3bde466d1 d23464c0ac60 fd958fbc06fb
- Depended on by
- a1b9869565aa 65be9f9c3074 5e6d6484c28e
- 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
- 22 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/hub/src/web/resolve.rs +90 −6
| … | 213 unchanged lines |
||
| 214 | 214 | Ok(None) |
|
| 215 | 215 | } |
|
| 216 | 216 | ||
| 217 | + | /// The versions of a binary file with a replace conflict at `path`, each |
|
| 218 | + | /// labelled with the change that wrote it. |
|
| 219 | + | fn replace_candidates( |
|
| 220 | + | r: &oxy_core::repo::Repo, |
|
| 221 | + | state: &oxy_core::state::State, |
|
| 222 | + | snapshot: &oxy_core::state::Snapshot, |
|
| 223 | + | path: &str, |
|
| 224 | + | ) -> Option<Vec<Candidate>> { |
|
| 225 | + | use oxy_core::state::FileContent; |
|
| 226 | + | let f = snapshot.files.get(path)?; |
|
| 227 | + | let FileContent::Opaque { blobs } = &f.content else { return None }; |
|
| 228 | + | if blobs.len() < 2 || f.kind != oxy_core::objects::NodeKind::File { |
|
| 229 | + | return None; |
|
| 230 | + | } |
|
| 231 | + | let writers = state.live_replaces(&f.node); |
|
| 232 | + | let mut out = Vec::new(); |
|
| 233 | + | for (i, b) in blobs.iter().enumerate() { |
|
| 234 | + | let content = r.read_blob(b).ok()?; |
|
| 235 | + | let by = writers.iter().find(|(_, n)| n == b).map(|(h, _)| state.label(h)).unwrap_or_default(); |
|
| 236 | + | let preview = std::str::from_utf8(&content).ok().map(|t| t.lines().take(40).collect::<Vec<_>>().join("\n")); |
|
| 237 | + | out.push(Candidate { path: format!("version {} from change {by}", i + 1), content, preview }); |
|
| 238 | + | } |
|
| 239 | + | Some(out) |
|
| 240 | + | } |
|
| 241 | + | ||
| 217 | 242 | /// What resolving `path` on a bookmark means. |
|
| 218 | 243 | enum Target { |
|
| 219 | 244 | /// A conflicted text file, rendered with markers. |
|
| 220 | 245 | Lines(String), |
|
| 221 | 246 | /// A name conflict: keep one candidate at `path`. |
|
| 222 | 247 | Name(Vec<Candidate>), |
|
| 248 | + | /// A replace conflict of a binary file: keep one of its versions. |
|
| 249 | + | Replace(Vec<Candidate>), |
|
| 223 | 250 | } |
|
| 224 | 251 | ||
| 225 | 252 | #[derive(Template)] |
|
| … | 5 unchanged lines |
||
| 231 | 258 | path: String, |
|
| 232 | 259 | state: String, |
|
| 233 | 260 | candidates: Vec<Candidate>, |
|
| 261 | + | /// Whether the candidates are versions of one file (a replace |
|
| 262 | + | /// conflict) rather than files wanting one name. |
|
| 263 | + | versions: bool, |
|
| 234 | 264 | vscode: String, |
|
| 235 | 265 | delegation: Option<String>, |
|
| 236 | 266 | error: Option<String>, |
|
| … | 27 unchanged lines |
||
| 264 | 294 | let heads = r.store().get_state(&st)?.heads; |
|
| 265 | 295 | let loaded = cache.load(r, &heads)?; |
|
| 266 | 296 | let names = name_candidates(r, &loaded.snapshot, &p); |
|
| 297 | + | let versions = replace_candidates(r, &loaded.state, &loaded.snapshot, &p); |
|
| 267 | 298 | let file = if names.as_ref().is_ok_and(|n| n.is_some()) { |
|
| 268 | 299 | None |
|
| 269 | 300 | } else { |
|
| 270 | 301 | let target = view::resolve(r, cache, &b)?; |
|
| 271 | 302 | Some(view::file(r, cache, target, &p, limit)) |
|
| 272 | 303 | }; |
|
| 273 | - | Ok(Some((st.to_hex(), names, file))) |
|
| 304 | + | Ok(Some((st.to_hex(), names, file, versions))) |
|
| 274 | 305 | }) |
|
| 275 | 306 | .await |
|
| 276 | 307 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 277 | - | let Some((state, names, file)) = found else { return Err(Error::bad(shell.clone(), "Conflicts are resolved on a bookmark.")) }; |
|
| 308 | + | let Some((state, names, file, versions)) = found else { |
|
| 309 | + | return Err(Error::bad(shell.clone(), "Conflicts are resolved on a bookmark.")); |
|
| 310 | + | }; |
|
| 311 | + | if let Some(v) = versions { |
|
| 312 | + | return Ok((state, Target::Replace(v))); |
|
| 313 | + | } |
|
| 278 | 314 | match names { |
|
| 279 | 315 | Ok(Some(c)) => return Ok((state, Target::Name(c))), |
|
| 280 | 316 | Err(msg) => return Err(Error::bad(shell.clone(), &msg)), |
|
| … | 28 unchanged lines |
||
| 309 | 345 | let (state, target) = load(app, &shell, &repo, bookmark, path).await?; |
|
| 310 | 346 | let text = match target { |
|
| 311 | 347 | Target::Lines(t) => t, |
|
| 312 | - | Target::Name(_) => return Err(Error::bad(shell, "That is a name conflict.")), |
|
| 348 | + | Target::Name(_) | Target::Replace(_) => return Err(Error::bad(shell, "That conflict is resolved by keeping a candidate.")), |
|
| 313 | 349 | }; |
|
| 314 | 350 | let Some(pieces) = parse(&text) else { |
|
| 315 | 351 | return Err(Error::bad(shell, "The conflict markers could not be read; resolve the file in the plain editor.")); |
|
| … | 26 unchanged lines |
||
| 342 | 378 | Ok(Redirect::to(&format!("/{owner}/{name}/edit/{bookmark}/{path}")).into_response()) |
|
| 343 | 379 | } |
|
| 344 | 380 | Target::Lines(_) => Ok(page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()), |
|
| 345 | - | Target::Name(_) => Ok(name_page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()), |
|
| 381 | + | Target::Name(_) | Target::Replace(_) => { |
|
| 382 | + | Ok(name_page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()) |
|
| 383 | + | } |
|
| 346 | 384 | } |
|
| 347 | 385 | } |
|
| 348 | 386 | ||
| … | 12 unchanged lines |
||
| 361 | 399 | let me = require(viewer, &shell)?; |
|
| 362 | 400 | let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?; |
|
| 363 | 401 | let (state, target) = load(app, &shell, &repo, bookmark, path).await?; |
|
| 364 | - | let Target::Name(candidates) = target else { return Err(Error::bad(shell, "That is not a name conflict.")) }; |
|
| 402 | + | let (candidates, versions) = match target { |
|
| 403 | + | Target::Name(c) => (c, false), |
|
| 404 | + | Target::Replace(c) => (c, true), |
|
| 405 | + | Target::Lines(_) => return Err(Error::bad(shell, "That conflict is resolved region by region.")), |
|
| 406 | + | }; |
|
| 365 | 407 | let delegation = signer_for(app, me.account.id).await.err(); |
|
| 366 | 408 | Ok(Page(NamePage { |
|
| 367 | 409 | shell, |
|
| … | 2 unchanged lines |
||
| 370 | 412 | path: path.to_string(), |
|
| 371 | 413 | state, |
|
| 372 | 414 | candidates, |
|
| 415 | + | versions, |
|
| 373 | 416 | vscode: vscode_link(app, owner, name, bookmark, path), |
|
| 374 | 417 | delegation, |
|
| 375 | 418 | error, |
|
| 376 | - | message: message.unwrap_or_else(|| format!("Resolve the name conflict at {path}")), |
|
| 419 | + | message: message.unwrap_or_else(|| { |
|
| 420 | + | if versions { |
|
| 421 | + | format!("Resolve the conflicting versions of {path}") |
|
| 422 | + | } else { |
|
| 423 | + | format!("Resolve the name conflict at {path}") |
|
| 424 | + | } |
|
| 425 | + | }), |
|
| 377 | 426 | })) |
|
| 378 | 427 | } |
|
| 379 | 428 | ||
| … | 17 unchanged lines |
||
| 397 | 446 | let stale = f.get("state") != Some(&state); |
|
| 398 | 447 | let text = match target { |
|
| 399 | 448 | Target::Lines(t) => t, |
|
| 449 | + | Target::Name(candidates) | Target::Replace(candidates) if candidates.is_empty() => { |
|
| 450 | + | return again("There are no candidates to keep.".into()).await; |
|
| 451 | + | } |
|
| 452 | + | Target::Replace(candidates) => { |
|
| 453 | + | let again = |msg: String| { |
|
| 454 | + | let (app, viewer, owner, name, bookmark, path, message) = |
|
| 455 | + | (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone()); |
|
| 456 | + | async move { |
|
| 457 | + | name_page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message)) |
|
| 458 | + | .await |
|
| 459 | + | .map(IntoResponse::into_response) |
|
| 460 | + | } |
|
| 461 | + | }; |
|
| 462 | + | if stale { |
|
| 463 | + | return again(format!("{bookmark} has moved since this page was loaded; the versions below are as they are now.")).await; |
|
| 464 | + | } |
|
| 465 | + | let Some(keep) = f.get("keep").and_then(|k| k.parse::<usize>().ok()).and_then(|k| candidates.get(k)) else { |
|
| 466 | + | return again("Choose the version to keep.".into()).await; |
|
| 467 | + | }; |
|
| 468 | + | if message.is_empty() { |
|
| 469 | + | return again("Describe the resolution.".into()).await; |
|
| 470 | + | } |
|
| 471 | + | let signer = match signer_for(&app, me.account.id).await { |
|
| 472 | + | Ok(s) => s, |
|
| 473 | + | Err(msg) => return again(msg).await, |
|
| 474 | + | }; |
|
| 475 | + | // Writing the kept version at the path settles the conflict. |
|
| 476 | + | let edit = vec![FileEdit { path: path.clone(), content: Some(keep.content.clone()) }]; |
|
| 477 | + | let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await { |
|
| 478 | + | Ok(d) => d, |
|
| 479 | + | Err(e) => return again(e.to_string()).await, |
|
| 480 | + | }; |
|
| 481 | + | after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await; |
|
| 482 | + | return Ok(Redirect::to(&format!("/{owner}/{name}/change/{}", done.change.to_hex())).into_response()); |
|
| 483 | + | } |
|
| 400 | 484 | Target::Name(candidates) => { |
|
| 401 | 485 | let again = |msg: String| { |
|
| 402 | 486 | let (app, viewer, owner, name, bookmark, path, message) = |
|
| … | 107 unchanged lines |
modified crates/hub/tests/edits.rs +52 −0
| … | 348 unchanged lines |
||
| 349 | 349 | let tree = send(app, "GET", "/andy/demo/tree/main", None, None).await.body; |
|
| 350 | 350 | assert!(!tree.contains("oxy-conflict"), "the candidates are gone"); |
|
| 351 | 351 | ||
| 352 | + | // A replace conflict: a binary file (a NUL makes it opaque) replaced |
|
| 353 | + | // two ways by proposals that do not know each other. |
|
| 354 | + | let r = send( |
|
| 355 | + | app, |
|
| 356 | + | "POST", |
|
| 357 | + | "/andy/demo/edit/main", |
|
| 358 | + | Some(&andy), |
|
| 359 | + | Some(&format!("_csrf={csrf}&path=img.bin&content=base%00&message=img&mode=commit")), |
|
| 360 | + | ) |
|
| 361 | + | .await; |
|
| 362 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 363 | + | for (b, text) in [("img-a", "alpha%00"), ("img-b", "beta%00")] { |
|
| 364 | + | let r = send( |
|
| 365 | + | app, |
|
| 366 | + | "POST", |
|
| 367 | + | "/andy/demo/edit/main", |
|
| 368 | + | Some(&andy), |
|
| 369 | + | Some(&format!("_csrf={csrf}&path=img.bin&original=img.bin&content={text}&message={b}&mode=propose&new_bookmark={b}")), |
|
| 370 | + | ) |
|
| 371 | + | .await; |
|
| 372 | + | assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body); |
|
| 373 | + | } |
|
| 374 | + | for n in [7, 8] { |
|
| 375 | + | let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await; |
|
| 376 | + | let r = |
|
| 377 | + | send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await; |
|
| 378 | + | assert!(r.body.contains("Landed."), "#{n}: {}", r.body); |
|
| 379 | + | } |
|
| 380 | + | let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body; |
|
| 381 | + | assert!(conflicts.contains("replace (2 candidates)") && conflicts.contains("/andy/demo/resolve/main/img.bin"), "{conflicts}"); |
|
| 382 | + | let page = send(app, "GET", "/andy/demo/resolve/main/img.bin", Some(&andy), None).await; |
|
| 383 | + | assert!(page.body.contains("has 2 versions") && page.body.contains("version 2 from change"), "{}", page.body); |
|
| 384 | + | let state = { |
|
| 385 | + | let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len(); |
|
| 386 | + | page.body[i..i + 64].to_string() |
|
| 387 | + | }; |
|
| 388 | + | let version = |text: &str| page.body.find(&format!("<pre class=\"marked\">{text}")).expect("a version's preview"); |
|
| 389 | + | let beta = if version("beta") < version("alpha") { 0 } else { 1 }; |
|
| 390 | + | let r = send( |
|
| 391 | + | app, |
|
| 392 | + | "POST", |
|
| 393 | + | "/andy/demo/resolve/main/img.bin", |
|
| 394 | + | Some(&andy), |
|
| 395 | + | Some(&format!("_csrf={csrf}&state={state}&keep={beta}&message={}", enc("Keep beta"))), |
|
| 396 | + | ) |
|
| 397 | + | .await; |
|
| 398 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 399 | + | assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict")); |
|
| 400 | + | assert_eq!(send(app, "GET", "/andy/demo/raw/main/img.bin", None, None).await.body, "beta\u{0}"); |
|
| 401 | + | let change = send(app, "GET", r.location.as_deref().unwrap(), Some(&andy), None).await.body; |
|
| 402 | + | assert!(change.contains(">resolution<"), "a resolution change"); |
|
| 403 | + | ||
| 352 | 404 | // Delete a file. |
|
| 353 | 405 | let r = send( |
|
| 354 | 406 | app, |
|
| … | 15 unchanged lines |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +1 −1
| … | 304 unchanged lines |
||
| 305 | 305 | ||
| 306 | 306 | ### 6.10 Conflicts page |
|
| 307 | 307 | ||
| 308 | - | For each bookmark whose state has conflicts: the file (or tree entry), the conflict kind, and each side with the change that produced it and its content, rendered with the same marker structure `oxy checkout` writes. For a text file, **resolve region by region** (`/{owner}/{name}/resolve/{bookmark}/{path}`, writers) shows the lines no one disagrees on and each conflict region with its sides and the change that wrote each; per region the user keeps one side, every side in order, none, or their own text (a side that records a deletion keeps the deletion). Regions are linkable (`#region-N`). The file is put back together and committed through the delegate as a web edit (§6.9), which makes it a `kind = resolution` change depending on every contributor; the page carries the bookmark's state and refuses to commit if the bookmark moved meanwhile. Malformed markers fall back to the editor, which also stays available: "edit the marked file" opens the markers in it. A name conflict (several files want one name, shown as `NAME.oxy-conflict-N`) resolves on the same URL by keeping one candidate: it takes the name as a move, keeping its identity, and the other candidates are removed. Conflicts between directories or links, and replace conflicts of binary files, are resolved from a client. Both resolver pages offer **Resolve in VS Code**, a `vscode://oxygen.oxygen-vscode/resolve?url=…&bookmark=…&path=…` link that oxygen-vscode handles: it finds the open checkout of that repository (or offers to clone it), switches to the bookmark after asking, pulls it, and opens the file. The change page tags resolution changes. Otherwise the page gives the two-line client recipe. |
|
| 308 | + | For each bookmark whose state has conflicts: the file (or tree entry), the conflict kind, and each side with the change that produced it and its content, rendered with the same marker structure `oxy checkout` writes. For a text file, **resolve region by region** (`/{owner}/{name}/resolve/{bookmark}/{path}`, writers) shows the lines no one disagrees on and each conflict region with its sides and the change that wrote each; per region the user keeps one side, every side in order, none, or their own text (a side that records a deletion keeps the deletion). Regions are linkable (`#region-N`). The file is put back together and committed through the delegate as a web edit (§6.9), which makes it a `kind = resolution` change depending on every contributor; the page carries the bookmark's state and refuses to commit if the bookmark moved meanwhile. Malformed markers fall back to the editor, which also stays available: "edit the marked file" opens the markers in it. A name conflict (several files want one name, shown as `NAME.oxy-conflict-N`) resolves on the same URL by keeping one candidate: it takes the name as a move, keeping its identity, and the other candidates are removed. A replace conflict (a binary file replaced two ways) resolves the same way by keeping one version, each labelled with the change that wrote it; the commit replaces every version with it (Oxygen spec §9.3). Conflicts between directories or links are resolved from a client. Both resolver pages offer **Resolve in VS Code**, a `vscode://oxygen.oxygen-vscode/resolve?url=…&bookmark=…&path=…` link that oxygen-vscode handles: it finds the open checkout of that repository (or offers to clone it), switches to the bookmark after asking, pulls it, and opens the file. The change page tags resolution changes. Otherwise the page gives the two-line client recipe. |
|
| 309 | 309 | ||
| 310 | 310 | ### 6.11 Trust page |
|
| 311 | 311 | ||
| … | 266 unchanged lines |
modified templates/conflicts.html +1 −1
| … | 11 unchanged lines |
||
| 12 | 12 | <header><span class="lbl lbl-bm">{{ c.bookmark }}</span> <strong>{{ c.path }}</strong> <span class="tag bad">{{ c.kind }}</span></header> |
|
| 13 | 13 | {% if !c.contributors.is_empty() %}<p class="small">Sides from: {% for x in c.contributors %}<a class="chip" href="{{ repo.url }}/change/{{ x.hash }}" title="{{ x.title }}">{{ x.short }}</a> {% endfor %}</p>{% endif %} |
|
| 14 | 14 | {% if let Some(text) = c.marked %}<pre class="marked">{{ text }}</pre>{% endif %} |
|
| 15 | - | {% if c.kind.starts_with("name:") && repo.can(crate::auth::Access::Write) %}<p><a class="go small" href="{{ repo.url }}/resolve/{{ c.bookmark }}/{{ c.path }}">choose which to keep</a></p>{% endif %} |
|
| 15 | + | {% if (c.kind.starts_with("name:") || c.kind.starts_with("replace")) && repo.can(crate::auth::Access::Write) %}<p><a class="go small" href="{{ repo.url }}/resolve/{{ c.bookmark }}/{{ c.path }}">choose which to keep</a></p>{% endif %} |
|
| 16 | 16 | {% if c.marked.is_some() && repo.can(crate::auth::Access::Write) %}<p><a class="go small" href="{{ repo.url }}/resolve/{{ c.bookmark }}/{{ c.path }}">resolve region by region</a> <span class="small mute">or <a href="{{ repo.url }}/edit/{{ c.bookmark }}/{{ c.path }}">edit the marked file</a></span></p>{% endif %} |
|
| 17 | 17 | <p class="small mute">Or resolve from a client: <code>oxy bookmark switch {{ c.bookmark }}</code>, edit the file, <code>oxy resolve</code>, <code>oxy commit</code>, <code>oxy push</code>.</p> |
|
| 18 | 18 | </section> |
|
| … | 3 unchanged lines |
modified templates/resolve_name.html +2 −2
| … | 5 unchanged lines |
||
| 6 | 6 | {% block mainclass %} class="wide"{% endblock %} |
|
| 7 | 7 | {% block content %} |
|
| 8 | 8 | <div class="head"><h1>Resolve <code>{{ path }}</code> <small>on <span class="lbl lbl-bm">{{ bookmark }}</span></small></h1> |
|
| 9 | - | <p class="small mute">{{ candidates.len() }} files want this name, so the tree shows them as <code>{{ path }}.oxy-conflict-N</code>. Keep one: it takes the name, keeping its history, and the others are removed. The commit is a resolution change on {{ bookmark }}.</p></div> |
|
| 9 | + | {% if versions %}<p class="small mute">Changes that did not know of each other each replaced this file, so it has {{ candidates.len() }} versions. Keep one: the commit is a resolution change on {{ bookmark }} that replaces every version with it.</p>{% else %}<p class="small mute">{{ candidates.len() }} files want this name, so the tree shows them as <code>{{ path }}.oxy-conflict-N</code>. Keep one: it takes the name, keeping its history, and the others are removed. The commit is a resolution change on {{ bookmark }}.</p>{% endif %}</div> |
|
| 10 | 10 | <p class="small"><a href="{{ vscode }}">Resolve in VS Code</a> <span class="mute">(with the Oxygen extension: it opens your checkout on {{ bookmark }}, or offers to clone it)</span></p> |
|
| 11 | 11 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 12 | 12 | {% if let Some(d) = delegation %}<p class="error">{{ d }}</p>{% endif %} |
|
| … | 2 unchanged lines |
||
| 15 | 15 | <input type="hidden" name="state" value="{{ state }}"> |
|
| 16 | 16 | {% for c in candidates %} |
|
| 17 | 17 | <section class="file-diff" id="candidate-{{ loop.index }}"> |
|
| 18 | - | <header><label><input type="radio" name="keep" value="{{ loop.index0 }}"{% if loop.first %} checked{% endif %}> keep <strong>{{ c.path }}</strong></label> <a class="small" href="{{ repo.url }}/file/{{ bookmark }}/{{ c.path }}">view</a></header> |
|
| 18 | + | <header><label><input type="radio" name="keep" value="{{ loop.index0 }}"{% if loop.first %} checked{% endif %}> keep <strong>{{ c.path }}</strong></label>{% if !versions %} <a class="small" href="{{ repo.url }}/file/{{ bookmark }}/{{ c.path }}">view</a>{% endif %}</header> |
|
| 19 | 19 | {% if let Some(p) = c.preview %}<pre class="marked">{{ p }}</pre>{% else %}<p class="small mute">binary, {{ c.content.len() }} bytes</p>{% endif %} |
|
| 20 | 20 | </section> |
|
| 21 | 21 | {% endfor %} |
|
| … | 4 unchanged lines |