| 1 | |
|
| 2 | |
|
| 3 | | use super::{filters, lookup, Error, Page, RepoShell, Shell}; |
| 4 | | use crate::auth::Viewer; |
| 5 | | use crate::render::{self, diff, graph}; |
| 6 | | use crate::repos::view; |
| 7 | | use crate::Shared; |
| 8 | | use askama::Template; |
| 9 | | use axum::extract::{Path, Query, State}; |
| 10 | | use axum::response::{IntoResponse, Response}; |
| 11 | | use oxy_core::repo::FileDiff; |
| 12 | | use serde::Deserialize; |
| 13 | | use std::collections::BTreeMap; |
| 14 | |
|
| 15 | | const PAGE: usize = 50; |
| 16 | |
|
| 17 | | #[derive(Deserialize)] |
| 18 | | pub struct HistoryQuery { |
| 19 | | path: Option<String>, |
| 20 | | offset: Option<usize>, |
| 21 | | } |
| 22 | |
|
| 23 | | #[derive(Template)] |
| 24 | | #[template(path = "history.html")] |
| 25 | | pub struct HistoryPage { |
| 26 | | shell: Shell, |
| 27 | | repo: RepoShell, |
| 28 | | h: view::History, |
| 29 | | rows: Vec<graph::Row>, |
| 30 | | lanes: usize, |
| 31 | | } |
| 32 | |
|
| 33 | 11× | pub async fn history( |
| 34 | 11× | State(app): State<Shared>, |
| 35 | 11× | viewer: Viewer, |
| 36 | 11× | Path((owner, name, r)): Path<(String, String, String)>, |
| 37 | 11× | Query(q): Query<HistoryQuery>, |
| 38 | 11× | ) -> Result<Page<HistoryPage>, Error> { |
| 39 | 11× | let shell = Shell::new(&app, &format!("/{owner}/{name}/changes/{r}"), &viewer); |
| 40 | 11× | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 41 | 10× | let spec = r.clone(); |
| 42 | 10× | let path = q.path.clone().filter(|p| !p.is_empty()); |
| 43 | 10× | let offset = q.offset.unwrap_or(0); |
| 44 | 10× | let h = app |
| 45 | 10× | .repos |
| 46 | 10× | .read(&repo, move |repo, cache| { |
| 47 | 10× | let target = view::resolve(repo, cache, &spec)?; |
| 48 | 10× | view::history(repo, cache, target, path.as_deref(), offset, PAGE) |
| 49 | 10× | }) |
| 50 | 10× | .await |
| 51 | 10× | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
| 52 | 10× | let mut h = h; |
| 53 | 10× | super::attribute(&app, &mut h.entries.iter_mut().collect::<Vec<_>>()).await; |
| 54 | 38× | let graph_input: Vec<(String, Vec<String>)> = h.entries.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect(); |
| 55 | 10× | let rows = graph::layout(&graph_input); |
| 56 | 10× | let lanes = graph::width(&rows); |
| 57 | 10× | Ok(Page(HistoryPage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), h, rows, lanes })) |
| 58 | 11× | } |
| 59 | |
|
| 60 | | #[derive(Deserialize)] |
| 61 | | pub struct FunctionQuery { |
| 62 | | start: usize, |
| 63 | | end: usize, |
| 64 | | #[serde(default)] |
| 65 | | name: String, |
| 66 | | } |
| 67 | |
|
| 68 | | #[derive(Template)] |
| 69 | | #[template(path = "function.html")] |
| 70 | | pub struct FunctionPage { |
| 71 | | shell: Shell, |
| 72 | | repo: RepoShell, |
| 73 | | r: String, |
| 74 | | path: String, |
| 75 | | name: String, |
| 76 | | start: usize, |
| 77 | | end: usize, |
| 78 | | entries: Vec<view::ChangeView>, |
| 79 | | rows: Vec<graph::Row>, |
| 80 | | lanes: usize, |
| 81 | | } |
| 82 | |
|
| 83 | |
|
| 84 | |
|
| 85 | |
|
| 86 | |
|
| 87 | 2× | pub async fn function( |
| 88 | 2× | State(app): State<Shared>, |
| 89 | 2× | viewer: Viewer, |
| 90 | 2× | Path((owner, name, r, path)): Path<(String, String, String, String)>, |
| 91 | 2× | Query(q): Query<FunctionQuery>, |
| 92 | 2× | ) -> Result<Page<FunctionPage>, Error> { |
| 93 | 2× | let shell = Shell::new(&app, &format!("/{owner}/{name}/function/{r}/{path}"), &viewer); |
| 94 | 2× | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 95 | 2× | let (spec, p, start, end) = (r.clone(), path.clone(), q.start, q.end); |
| 96 | 2× | let mut entries = app |
| 97 | 2× | .repos |
| 98 | 2× | .read(&repo, move |repo, cache| { |
| 99 | 2× | let target = view::resolve(repo, cache, &spec)?; |
| 100 | 2× | let history = repo.line_history(&target.heads, &p, start, end)?; |
| 101 | 1× | let labels = view::head_labels(repo)?; |
| 102 | 2× | Ok(history.iter().map(|e| view::change_view_pub(repo, &e.hash, &e.change, e.is_head, &labels)).collect::<Vec<_>>()) |
| 103 | 2× | }) |
| 104 | 2× | .await |
| 105 | 2× | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
| 106 | | |
| 107 | 2× | let listed: std::collections::HashSet<String> = entries.iter().map(|c| c.hash.clone()).collect(); |
| 108 | 2× | for c in &mut entries { |
| 109 | 2× | c.deps.retain(|d| listed.contains(d)); |
| 110 | | } |
| 111 | 1× | super::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await; |
| 112 | 2× | let graph_input: Vec<(String, Vec<String>)> = entries.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect(); |
| 113 | 1× | let rows = graph::layout(&graph_input); |
| 114 | 1× | let lanes = graph::width(&rows); |
| 115 | 1× | Ok(Page(FunctionPage { |
| 116 | 1× | shell, |
| 117 | 1× | repo: RepoShell::new(&app, &row, "code").with_role(role), |
| 118 | 1× | r, |
| 119 | 1× | path, |
| 120 | 1× | name: q.name, |
| 121 | 1× | start, |
| 122 | 1× | end, |
| 123 | 1× | entries, |
| 124 | 1× | rows, |
| 125 | 1× | lanes, |
| 126 | 1× | })) |
| 127 | 2× | } |
| 128 | |
|
| 129 | |
|
| 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 | | proposal: Option<i64>, |
| 143 | | } |
| 144 | |
|
| 145 | |
|
| 146 | |
|
| 147 | |
|
| 148 | 3× | pub async fn lanes( |
| 149 | 3× | State(app): State<Shared>, |
| 150 | 3× | viewer: Viewer, |
| 151 | 3× | Path((owner, name, r)): Path<(String, String, String)>, |
| 152 | 3× | ) -> Result<Page<LanesPage>, Error> { |
| 153 | 3× | let shell = Shell::new(&app, &format!("/{owner}/{name}/lanes/{r}"), &viewer); |
| 154 | 3× | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 155 | 3× | let spec = r.clone(); |
| 156 | 3× | let h = app |
| 157 | 3× | .repos |
| 158 | 3× | .read(&repo, move |repo, cache| { |
| 159 | 3× | let target = view::resolve(repo, cache, &spec)?; |
| 160 | 3× | view::history(repo, cache, target, None, 0, BOARD) |
| 161 | 3× | }) |
| 162 | 3× | .await |
| 163 | 3× | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
| 164 | 3× | let board = graph::board(&h.entries); |
| 165 | 3× | let proposal = editable_proposal(&app, &viewer, &row, role, &r).await.map(|p| p.number); |
| 166 | 3× | Ok(Page(LanesPage { |
| 167 | 3× | shell, |
| 168 | 3× | repo: RepoShell::new(&app, &row, "changes").with_role(role), |
| 169 | 3× | target: h.target, |
| 170 | 3× | total: h.total, |
| 171 | 3× | board, |
| 172 | 3× | proposal, |
| 173 | 3× | })) |
| 174 | 3× | } |
| 175 | |
|
| 176 | |
|
| 177 | |
|
| 178 | |
|
| 179 | 7× | async fn editable_proposal( |
| 180 | 7× | app: &Shared, |
| 181 | 7× | viewer: &Viewer, |
| 182 | 7× | row: &crate::db::repos::RepoRow, |
| 183 | 7× | role: crate::auth::Role, |
| 184 | 7× | bookmark: &str, |
| 185 | 7× | ) -> Option<crate::db::proposals::ProposalRow> { |
| 186 | 7× | let me = viewer.account()?; |
| 187 | 6× | let open = crate::db::proposals::list(&app.db, row.id, &["open", "draft"]).await.ok()?; |
| 188 | 6× | open.into_iter().find(|p| { |
| 189 | 5× | p.source_bookmark == bookmark |
| 190 | 5× | && p.source_repo_id.is_none_or(|id| id == row.id) |
| 191 | 5× | && (p.author_id == Some(me.id) || role >= crate::auth::Role::Write) |
| 192 | 5× | }) |
| 193 | 7× | } |
| 194 | |
|
| 195 | |
|
| 196 | | #[derive(serde::Serialize)] |
| 197 | | pub struct PlanOption { |
| 198 | | |
| 199 | | kind: &'static str, |
| 200 | | summary: String, |
| 201 | | |
| 202 | | command: String, |
| 203 | | |
| 204 | | vscode: String, |
| 205 | | } |
| 206 | |
|
| 207 | | #[derive(Deserialize)] |
| 208 | | pub struct PlanForm { |
| 209 | | #[serde(rename = "_csrf")] |
| 210 | | csrf_token: String, |
| 211 | | |
| 212 | | change: String, |
| 213 | | target: String, |
| 214 | | } |
| 215 | |
|
| 216 | |
|
| 217 | |
|
| 218 | |
|
| 219 | |
|
| 220 | 4× | 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 | 4× | if c == t || !state.contains(c) || !state.contains(t) || state.depends_on(t, c) { |
| 223 | 1× | return vec![]; |
| 224 | 3× | } |
| 225 | 3× | let mut out = Vec::new(); |
| 226 | 3× | let closure = state.closure(t); |
| 227 | 3× | let deps_ok = state.get(c).is_some_and(|m| m.deps.iter().all(|d| d == t || closure.contains(d))); |
| 228 | 3× | if deps_ok { |
| 229 | 2× | out.push("squash"); |
| 230 | 2× | } |
| 231 | 3× | if !state.get(c).is_some_and(|m| m.deps.contains(t)) { |
| 232 | 1× | out.push("rebase"); |
| 233 | 2× | } |
| 234 | 3× | out |
| 235 | 4× | } |
| 236 | |
|
| 237 | |
|
| 238 | |
|
| 239 | |
|
| 240 | 4× | pub async fn plan( |
| 241 | 4× | State(app): State<Shared>, |
| 242 | 4× | viewer: Viewer, |
| 243 | 4× | Path((owner, name, r)): Path<(String, String, String)>, |
| 244 | 4× | axum::extract::Form(f): axum::extract::Form<PlanForm>, |
| 245 | 4× | ) -> Result<Response, Error> { |
| 246 | 4× | let shell = Shell::new(&app, &format!("/{owner}/{name}/lanes/{r}"), &viewer); |
| 247 | 4× | super::settings::csrf(&viewer, &f.csrf_token, &shell)?; |
| 248 | 4× | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 249 | 4× | let problem = |msg: &str| (axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": msg }))).into_response(); |
| 250 | 4× | let Some(proposal) = editable_proposal(&app, &viewer, &row, role, &r).await else { |
| 251 | 0× | return Ok(problem("Changes are rearranged only on the bookmark of an open proposal you may change.")); |
| 252 | | }; |
| 253 | 4× | let (Some(c), Some(t)) = (oxy_core::hash::ChangeHash::from_hex(&f.change), oxy_core::hash::ChangeHash::from_hex(&f.target)) else { |
| 254 | 0× | return Ok(problem("Name two changes.")); |
| 255 | | }; |
| 256 | 4× | let (spec, target_bookmark) = (r.clone(), proposal.target.clone()); |
| 257 | 4× | let found = app |
| 258 | 4× | .repos |
| 259 | 4× | .read(&repo, move |repo, cache| { |
| 260 | 4× | let here = view::resolve(repo, cache, &spec)?; |
| 261 | 4× | let loaded = cache.load(repo, &here.heads)?; |
| 262 | | |
| 263 | 4× | let landed = match repo.view()?.bookmarks.get(&target_bookmark) { |
| 264 | 4× | Some(st) => { |
| 265 | 4× | let heads = repo.store().get_state(st)?.heads; |
| 266 | 4× | let target_state = cache.load(repo, &heads)?; |
| 267 | 4× | target_state.state.contains(&c) || target_state.state.contains(&t) |
| 268 | | } |
| 269 | 0× | None => false, |
| 270 | | }; |
| 271 | 8× | let meta = |h: &oxy_core::hash::ChangeHash| loaded.state.get(h).map(|m| m.change_id.short(8)); |
| 272 | 4× | Ok((plan_options(&loaded.state, &c, &t), landed, meta(&c), meta(&t))) |
| 273 | 4× | }) |
| 274 | 4× | .await |
| 275 | 4× | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
| 276 | 4× | let (kinds, landed, Some(cid), Some(tid)) = found else { |
| 277 | 0× | return Ok(problem("Both changes must be on this bookmark.")); |
| 278 | | }; |
| 279 | 4× | if landed { |
| 280 | 1× | return Ok(problem(&format!("That change is already on {}; only the proposal's own changes are rearranged.", proposal.target))); |
| 281 | 3× | } |
| 282 | 3× | if kinds.is_empty() { |
| 283 | 1× | return Ok(problem("That drop means nothing here: the change is already part of the other, or they are the same.")); |
| 284 | 2× | } |
| 285 | 2× | let base = format!("{}/{owner}/{name}", app.config.base_url().trim_end_matches('/')); |
| 286 | 2× | let enc = super::settings::urlencode; |
| 287 | 2× | let options: Vec<PlanOption> = kinds |
| 288 | 2× | .into_iter() |
| 289 | 3× | .map(|kind| { |
| 290 | 3× | let (summary, command) = match kind { |
| 291 | 3× | "squash" => ( |
| 292 | 2× | format!("Squash {cid} into {tid}: one change instead of two."), |
| 293 | 2× | format!("oxy squash #{} --into #{}", c.to_hex(), t.to_hex()), |
| 294 | 2× | ), |
| 295 | 1× | _ => ( |
| 296 | 1× | format!("Rebase {cid} onto {tid}: {cid} comes to depend on {tid}."), |
| 297 | 1× | format!("oxy rebase #{} --onto #{}", c.to_hex(), t.to_hex()), |
| 298 | 1× | ), |
| 299 | | }; |
| 300 | 3× | let vscode = format!( |
| 301 | | "vscode://oxygenhub.oxygen-vscode/plan?url={}&bookmark={}&kind={kind}&change={}&target={}", |
| 302 | 3× | enc(&base), |
| 303 | 3× | enc(&r), |
| 304 | 3× | c.to_hex(), |
| 305 | 3× | t.to_hex() |
| 306 | | ); |
| 307 | 3× | PlanOption { kind, summary, command: format!("oxy bookmark switch {r} && {command} && oxy push --force"), vscode } |
| 308 | 3× | }) |
| 309 | 2× | .collect(); |
| 310 | 2× | Ok(axum::Json(serde_json::json!({ "proposal": proposal.number, "options": options })).into_response()) |
| 311 | 4× | } |
| 312 | |
|
| 313 | |
|
| 314 | | pub struct FileRows { |
| 315 | | pub path: String, |
| 316 | | pub from: Option<String>, |
| 317 | | pub kind: String, |
| 318 | | pub lines: Vec<diff::Line>, |
| 319 | | pub added: usize, |
| 320 | | pub removed: usize, |
| 321 | | pub anchor: String, |
| 322 | | } |
| 323 | |
|
| 324 | 114× | pub fn render_files(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>) -> Vec<FileRows> { |
| 325 | 114× | render_files_with(files, bases, diff::Options::default()) |
| 326 | 114× | } |
| 327 | |
|
| 328 | |
|
| 329 | 132× | pub fn render_files_with(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>, opts: diff::Options) -> Vec<FileRows> { |
| 330 | 132× | files |
| 331 | 132× | .iter() |
| 332 | 132× | .enumerate() |
| 333 | 132× | .map(|(i, f)| { |
| 334 | 131× | let base = bases.get(&f.path).map(Vec::as_slice).unwrap_or(b""); |
| 335 | 131× | let lines = diff::unified_with(base, &f.hunks, 3, opts); |
| 336 | | FileRows { |
| 337 | 131× | path: f.path.clone(), |
| 338 | 131× | from: f.from.clone(), |
| 339 | 131× | kind: f.kind.clone(), |
| 340 | 331× | added: lines.iter().filter(|l| l.kind == diff::Kind::Add).count(), |
| 341 | 331× | removed: lines.iter().filter(|l| l.kind == diff::Kind::Del).count(), |
| 342 | 131× | lines, |
| 343 | 131× | anchor: format!("diff-{i}"), |
| 344 | | } |
| 345 | 131× | }) |
| 346 | 132× | .collect() |
| 347 | 132× | } |
| 348 | |
|
| 349 | | #[derive(Template)] |
| 350 | | #[template(path = "change.html")] |
| 351 | | pub struct ChangePage { |
| 352 | | shell: Shell, |
| 353 | | repo: RepoShell, |
| 354 | | c: view::ChangeDetail, |
| 355 | | files: Vec<FileRows>, |
| 356 | | body_html: String, |
| 357 | | ignore_ws: bool, |
| 358 | | |
| 359 | | receipts: Vec<super::flow::ReceiptRow>, |
| 360 | | |
| 361 | | can_run: bool, |
| 362 | | } |
| 363 | |
|
| 364 | |
|
| 365 | | #[derive(Deserialize, Default)] |
| 366 | | pub struct DiffQuery { |
| 367 | | #[serde(default)] |
| 368 | | w: Option<String>, |
| 369 | | } |
| 370 | |
|
| 371 | | impl DiffQuery { |
| 372 | 36× | pub fn options(&self) -> diff::Options { |
| 373 | 36× | diff::Options { ignore_whitespace: self.w.as_deref() == Some("1") } |
| 374 | 36× | } |
| 375 | | } |
| 376 | |
|
| 377 | 13× | pub async fn change( |
| 378 | 13× | State(app): State<Shared>, |
| 379 | 13× | viewer: Viewer, |
| 380 | 13× | Path((owner, name, hash)): Path<(String, String, String)>, |
| 381 | 13× | Query(q): Query<DiffQuery>, |
| 382 | 13× | ) -> Result<Page<ChangePage>, Error> { |
| 383 | 13× | let shell = Shell::new(&app, &format!("/{owner}/{name}/change/{hash}"), &viewer); |
| 384 | 13× | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 385 | 13× | let spec = hash.clone(); |
| 386 | 13× | let c = app |
| 387 | 13× | .repos |
| 388 | 13× | .read(&repo, move |repo, cache| { |
| 389 | 13× | let h = view::resolve_change(repo, cache, &spec)?; |
| 390 | 13× | view::change(repo, cache, &h) |
| 391 | 13× | }) |
| 392 | 13× | .await |
| 393 | 13× | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
| 394 | 13× | let mut c = c; |
| 395 | | { |
| 396 | 13× | let mut all: Vec<&mut view::ChangeView> = vec![&mut c.view]; |
| 397 | 13× | all.extend(c.deps.iter_mut()); |
| 398 | 13× | all.extend(c.dependents.iter_mut()); |
| 399 | 13× | super::attribute(&app, &mut all).await; |
| 400 | | } |
| 401 | 13× | let files = render_files_with(&c.files, &c.bases, q.options()); |
| 402 | 13× | let ignore_ws = q.options().ignore_whitespace; |
| 403 | 13× | let body_html = |
| 404 | 13× | if c.view.body.is_empty() { String::new() } else { format!("<pre class=\"msg\">{}</pre>", render::escape(&c.view.body)) }; |
| 405 | 13× | let receipts = match oxy_core::hash::ChangeHash::from_hex(&c.view.hash) { |
| 406 | 13× | Some(h) => super::flow::receipts_of(&app, &repo, vec![h]).await, |
| 407 | 0× | None => vec![], |
| 408 | | }; |
| 409 | 13× | let can_run = super::flow::why_not_run(crate::flow::enabled(&app, &row), viewer.account().is_some(), role).is_none(); |
| 410 | 13× | Ok(Page(ChangePage { |
| 411 | 13× | shell, |
| 412 | 13× | repo: RepoShell::new(&app, &row, "changes").with_role(role), |
| 413 | 13× | c, |
| 414 | 13× | files, |
| 415 | 13× | body_html, |
| 416 | 13× | ignore_ws, |
| 417 | 13× | receipts, |
| 418 | 13× | can_run, |
| 419 | 13× | })) |
| 420 | 13× | } |
| 421 | |
|
| 422 | | #[derive(Template)] |
| 423 | | #[template(path = "compare.html")] |
| 424 | | pub struct ComparePage { |
| 425 | | shell: Shell, |
| 426 | | repo: RepoShell, |
| 427 | | cmp: view::Compare, |
| 428 | | files: Vec<FileRows>, |
| 429 | | ignore_ws: bool, |
| 430 | | |
| 431 | | base_spec: String, |
| 432 | | head_spec: String, |
| 433 | | |
| 434 | | bookmarks: Vec<String>, |
| 435 | | tags: Vec<String>, |
| 436 | | propose: Option<Propose>, |
| 437 | | } |
| 438 | |
|
| 439 | |
|
| 440 | | pub struct Propose { |
| 441 | | pub url: String, |
| 442 | | |
| 443 | | pub existing: Option<i64>, |
| 444 | | } |
| 445 | |
|
| 446 | | #[derive(Deserialize)] |
| 447 | | pub struct CompareForm { |
| 448 | | base: Option<String>, |
| 449 | | head: Option<String>, |
| 450 | | } |
| 451 | |
|
| 452 | |
|
| 453 | |
|
| 454 | 2× | pub async fn compare_index( |
| 455 | 2× | State(app): State<Shared>, |
| 456 | 2× | viewer: Viewer, |
| 457 | 2× | Path((owner, name)): Path<(String, String)>, |
| 458 | 2× | Query(q): Query<CompareForm>, |
| 459 | 2× | ) -> Result<Response, Error> { |
| 460 | 2× | let shell = Shell::new(&app, &format!("/{owner}/{name}/compare"), &viewer); |
| 461 | 2× | let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 462 | 4× | let clean = |s: Option<String>| s.map(|x| x.trim().to_string()).filter(|x| !x.is_empty() && !x.contains("...")); |
| 463 | 2× | let (base, head) = match (clean(q.base), clean(q.head)) { |
| 464 | 1× | (Some(b), Some(h)) => (b, h), |
| 465 | 1× | (b, h) => { |
| 466 | 1× | let default = row.default_bookmark.clone(); |
| 467 | 1× | let d = default.clone(); |
| 468 | 1× | let newest = app |
| 469 | 1× | .repos |
| 470 | 1× | .read(&repo, move |r, cache| { |
| 471 | 2× | Ok(view::bookmarks(r, cache, &d)?.into_iter().filter(|b| !b.default).max_by_key(|b| b.latest_ms).map(|b| b.name)) |
| 472 | 1× | }) |
| 473 | 1× | .await |
| 474 | 1× | .ok() |
| 475 | 1× | .flatten(); |
| 476 | 1× | (b.unwrap_or_else(|| default.clone()), h.unwrap_or_else(|| newest.unwrap_or(default))) |
| 477 | | } |
| 478 | | }; |
| 479 | 4× | let enc = |s: &str| s.replace('%', "%25").replace(' ', "%20").replace('?', "%3F").replace('#', "%23"); |
| 480 | 2× | Ok(axum::response::Redirect::to(&format!("/{owner}/{name}/compare/{}...{}", enc(&base), enc(&head))).into_response()) |
| 481 | 2× | } |
| 482 | |
|
| 483 | |
|
| 484 | 6× | pub async fn compare( |
| 485 | 6× | State(app): State<Shared>, |
| 486 | 6× | viewer: Viewer, |
| 487 | 6× | Path((owner, name, spec)): Path<(String, String, String)>, |
| 488 | 6× | Query(q): Query<DiffQuery>, |
| 489 | 6× | ) -> Result<Page<ComparePage>, Error> { |
| 490 | 6× | let shell = Shell::new(&app, &format!("/{owner}/{name}/compare/{spec}"), &viewer); |
| 491 | 6× | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 492 | 6× | let Some((a, b)) = spec.split_once("...") else { |
| 493 | 1× | return Err(Error::bad(shell, "compare takes base...head")); |
| 494 | | }; |
| 495 | 5× | let (a, b) = (a.to_string(), b.to_string()); |
| 496 | 5× | let cmp = app |
| 497 | 5× | .repos |
| 498 | 5× | .read(&repo, move |repo, cache| { |
| 499 | 5× | let base = view::resolve(repo, cache, &a)?; |
| 500 | 5× | let head = view::resolve(repo, cache, &b)?; |
| 501 | 5× | view::compare(repo, cache, base, head) |
| 502 | 5× | }) |
| 503 | 5× | .await |
| 504 | 5× | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
| 505 | 5× | let mut cmp = cmp; |
| 506 | 5× | super::attribute(&app, &mut cmp.added.iter_mut().collect::<Vec<_>>()).await; |
| 507 | 5× | let files = render_files_with(&cmp.files, &cmp.bases, q.options()); |
| 508 | 5× | let ignore_ws = q.options().ignore_whitespace; |
| 509 | 5× | let rs = RepoShell::new(&app, &row, "changes").with_role(role); |
| 510 | 5× | let (bookmarks, tags) = app |
| 511 | 5× | .repos |
| 512 | 5× | .read(&repo, |r, _| { |
| 513 | 5× | let b: Vec<String> = r.bookmarks()?.into_iter().map(|b| b.name).collect(); |
| 514 | 5× | let t: Vec<String> = view::tags(r)?.into_iter().map(|t| t.name).collect(); |
| 515 | 5× | Ok((b, t)) |
| 516 | 5× | }) |
| 517 | 5× | .await |
| 518 | 5× | .unwrap_or_default(); |
| 519 | 5× | let both_bookmarks = cmp.base.kind == view::RefKind::Bookmark && cmp.head.kind == view::RefKind::Bookmark; |
| 520 | 5× | let propose = if both_bookmarks && cmp.base.label != cmp.head.label && !cmp.added.is_empty() && rs.can(crate::auth::Access::Write) { |
| 521 | 2× | let open = crate::db::proposals::list(&app.db, row.id, &["open"]).await.unwrap_or_default(); |
| 522 | 2× | let existing = open |
| 523 | 2× | .iter() |
| 524 | 2× | .find(|p| p.source_kind == "bookmark" && p.source_bookmark == cmp.head.label && p.target == cmp.base.label) |
| 525 | 2× | .map(|p| p.number); |
| 526 | 2× | Some(Propose { url: format!("{}/proposals/new?source={}&target={}", rs.url, cmp.head.label, cmp.base.label), existing }) |
| 527 | | } else { |
| 528 | 3× | None |
| 529 | | }; |
| 530 | 5× | let (base_spec, head_spec) = spec.split_once("...").map(|(a, b)| (a.to_string(), b.to_string())).unwrap_or_default(); |
| 531 | 5× | Ok(Page(ComparePage { shell, repo: rs, cmp, files, ignore_ws, base_spec, head_spec, bookmarks, tags, propose })) |
| 532 | 6× | } |