Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
1//! Landing page and dashboard.
2
3use axum::extract::State;
4use axum::response::{IntoResponse, Response};
5use chrono::{DateTime, Utc};
6
7use crate::error::AppResult;
8use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
9use crate::views::pages::{
10 ActivityItem, BookmarkItem, Dashboard, DashChange, FeedItem, RepoSummary, StackItem,
11};
12use crate::views::{self, Chrome};
13
14/// Repos the viewer can reach: their own, plus any they collaborate on, plus
15/// any belonging to an org they are a member of, plus public repos.
16///
17/// Visibility is enforced *in* the query rather than by filtering afterwards —
18/// a post-filter is how private repos leak into listings. `$1` is the viewer's
19/// id and `$2` is whether they are an admin.
20const VISIBLE_TO_VIEWER: &str = r#"
21 r.archived = false
22 AND (
23 r.visibility = 'public'
24 OR r.owner_user_id = $1
25 OR EXISTS (SELECT 1 FROM repo_collaborators c
26 WHERE c.repo_id = r.id AND c.user_id = $1)
27 OR EXISTS (SELECT 1 FROM org_members m
28 WHERE m.org_id = r.owner_org_id AND m.user_id = $1)
29 OR $2
30 )
31"#;
32
33/// `/` — landing page when signed out, dashboard when signed in.
34///
35/// Kept dual-mode rather than redirecting: every existing link and bookmark to
36/// `/` keeps working, and `/dashboard` below serves the same body for the
37/// design's explicit dashboard links.
38pub async fn index(
39 State(state): State<AppState>,
40 CurrentUser(user): CurrentUser,
41 CsrfToken(csrf): CsrfToken,
42 Nonce(nonce): Nonce,
43) -> AppResult<Response> {
44 let Some(user) = user else {
45 let feed = public_feed(&state).await?;
46 let repos = public_repos(&state).await?;
47 let in_flight = public_in_flight(&state).await?;
48 let bookmarks = public_bookmarks(&state).await?;
49
50 // The clone line names a repository the visitor can actually clone
51 // when there is one, and only falls back to a placeholder on an
52 // instance with nothing public in it.
53 let sample = repos.first().map(|r| format!("{}/{}", r.owner, r.name));
54 let hint = match repos.first() {
55 Some(r) => state.config.https_clone_url(&r.owner, &r.name),
56 None => state.config.https_clone_url("your-org", "your-repo"),
57 };
58
59 return Ok(views::page_full(
60 Chrome { title: "Dogfood", user: None, csrf: &csrf, nonce: &nonce },
61 views::pages::landing(views::pages::Landing {
62 clone_hint: &format!("jj git clone {hint}"),
63 sample_repo: sample.as_deref(),
64 feed: &feed,
65 repos: &repos,
66 in_flight: &in_flight,
67 bookmarks: &bookmarks,
68 }),
69 )
70 .into_response());
71 };
72
73 render_dashboard(&state, &user, &csrf, &nonce).await
74}
75
76/// `/dashboard` — the signed-in dashboard, addressable on its own.
77///
78/// A signed-out visitor gets the landing page rather than a login redirect: the
79/// dashboard is not a secret, it is just empty without an account, and bouncing
80/// somebody to an SSO round trip to learn that is worse.
81pub async fn dashboard(
82 State(state): State<AppState>,
83 CurrentUser(user): CurrentUser,
84 CsrfToken(csrf): CsrfToken,
85 Nonce(nonce): Nonce,
86) -> AppResult<Response> {
87 match user {
88 Some(user) => render_dashboard(&state, &user, &csrf, &nonce).await,
89 None => Ok(axum::response::Redirect::to("/").into_response()),
90 }
91}
92
93async fn render_dashboard(
94 state: &AppState,
95 user: &df_db::models::User,
96 csrf: &str,
97 nonce: &str,
98) -> AppResult<Response> {
99 let repos = visible_repos(state, user).await?;
100 let awaiting = awaiting_review(state, user).await?;
101 let mine = my_open_changes(state, user).await?;
102 let activity = watched_activity(state, user).await?;
103
104 Ok(views::page(
105 Chrome { title: "Dashboard", user: Some(user), csrf, nonce },
106 views::pages::dashboard(Dashboard {
107 user,
108 awaiting: &awaiting,
109 mine: &mine,
110 activity: &activity,
111 repos: &repos,
112 }),
113 )
114 .into_response())
115}
116
117// ─── queries ─────────────────────────────────────────────────────────────────
118
119type RepoRow = (
120 String,
121 String,
122 Option<String>,
123 bool,
124 i64,
125 i64,
126 Option<DateTime<Utc>>,
127);
128
129fn to_summaries(rows: Vec<RepoRow>) -> Vec<RepoSummary> {
130 rows.into_iter()
131 .map(
132 |(owner, name, description, private, open_changes, conflicted, pushed_at)| RepoSummary {
133 owner,
134 name,
135 description,
136 private,
137 open_changes,
138 conflicted,
139 pushed_at,
140 },
141 )
142 .collect()
143}
144
145/// The card's counts, as correlated subqueries.
146///
147/// A `LEFT JOIN … GROUP BY` would need two conditional aggregates over the same
148/// join and would still have to handle the no-changes case; two scalar
149/// subqueries against `(repo_id, state)` are both cheaper and easier to read.
150const REPO_CARD_COUNTS: &str = r#"
151 (SELECT count(*) FROM changes c
152 WHERE c.repo_id = r.id AND c.state = 'open') AS open_changes,
153 (SELECT count(*) FROM changes c
154 WHERE c.repo_id = r.id AND c.state = 'open' AND c.conflicted) AS conflicted,
155 r.pushed_at
156"#;
157
158async fn visible_repos(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<RepoSummary>> {
159 let sql = format!(
160 r#"
161 SELECT COALESCE(ou.handle, og.handle) AS owner,
162 r.name::text,
163 r.description,
164 (r.visibility = 'private') AS private,
165 {REPO_CARD_COUNTS}
166 FROM repos r
167 LEFT JOIN users ou ON ou.id = r.owner_user_id
168 LEFT JOIN orgs og ON og.id = r.owner_org_id
169 WHERE {VISIBLE_TO_VIEWER}
170 ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
171 LIMIT 50
172 "#
173 );
174
175 let rows: Vec<RepoRow> = sqlx::query_as(&sql)
176 .bind(user.id)
177 .bind(user.is_admin)
178 .fetch_all(&state.db)
179 .await?;
180
181 Ok(to_summaries(rows))
182}
183
184/// Public repositories, for the signed-out landing page.
185async fn public_repos(state: &AppState) -> AppResult<Vec<RepoSummary>> {
186 let rows: Vec<RepoRow> = sqlx::query_as(&format!(
187 r#"
188 SELECT COALESCE(ou.handle, og.handle) AS owner,
189 r.name::text,
190 r.description,
191 false AS private,
192 {REPO_CARD_COUNTS}
193 FROM repos r
194 LEFT JOIN users ou ON ou.id = r.owner_user_id
195 LEFT JOIN orgs og ON og.id = r.owner_org_id
196 WHERE r.archived = false AND r.visibility = 'public'
197 ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
198 LIMIT 6
199 "#
200 ))
201 .fetch_all(&state.db)
202 .await?;
203
204 Ok(to_summaries(rows))
205}
206
207/// Open public changes that are part of a stack — the landing page's
208/// "in flight now".
209///
210/// "Part of a stack" is exactly "has an edge in `change_edges`": a change with
211/// a parent or a child is one somebody is building on or building from. A lone
212/// open change is work, but it is not a stack, and the panel is about the thing
213/// branches cannot represent.
214async fn public_in_flight(state: &AppState) -> AppResult<Vec<StackItem>> {
215 /// `(owner, repo, number, change_id, synthetic, title, conflicted, updated_at)`
216 type Row = (String, String, i64, String, bool, String, bool, DateTime<Utc>);
217
218 let rows: Vec<Row> = sqlx::query_as(
219 r#"
220 SELECT COALESCE(ou.handle, og.handle) AS owner,
221 r.name::text,
222 c.number,
223 c.change_id,
224 c.synthetic,
225 c.title,
226 c.conflicted,
227 c.updated_at
228 FROM changes c
229 JOIN repos r ON r.id = c.repo_id
230 LEFT JOIN users ou ON ou.id = r.owner_user_id
231 LEFT JOIN orgs og ON og.id = r.owner_org_id
232 WHERE r.archived = false
233 AND r.visibility = 'public'
234 AND c.state = 'open'
235 AND EXISTS (
236 SELECT 1 FROM change_edges e
237 WHERE e.child_change = c.id OR e.parent_change = c.id
238 )
239 ORDER BY c.updated_at DESC
240 LIMIT 4
241 "#,
242 )
243 .fetch_all(&state.db)
244 .await?;
245
246 Ok(rows
247 .into_iter()
248 .map(
249 |(owner, repo, number, change_id, synthetic, title, conflicted, when)| StackItem {
250 owner,
251 repo,
252 number,
253 change_id,
254 synthetic,
255 title,
256 conflicted,
257 when,
258 },
259 )
260 .collect())
261}
262
263/// Recently moved bookmarks across public repositories.
264async fn public_bookmarks(state: &AppState) -> AppResult<Vec<BookmarkItem>> {
265 let rows: Vec<(String, String, String, bool, DateTime<Utc>)> = sqlx::query_as(
266 r#"
267 SELECT COALESCE(ou.handle, og.handle) AS owner,
268 r.name::text,
269 b.name AS bookmark,
270 b.protected,
271 b.updated_at
272 FROM bookmarks b
273 JOIN repos r ON r.id = b.repo_id
274 LEFT JOIN users ou ON ou.id = r.owner_user_id
275 LEFT JOIN orgs og ON og.id = r.owner_org_id
276 WHERE r.archived = false AND r.visibility = 'public'
277 ORDER BY b.updated_at DESC
278 LIMIT 5
279 "#,
280 )
281 .fetch_all(&state.db)
282 .await?;
283
284 Ok(rows
285 .into_iter()
286 .map(|(owner, repo, name, protected, updated_at)| BookmarkItem {
287 owner,
288 repo,
289 name,
290 protected,
291 updated_at,
292 })
293 .collect())
294}
295
296/// The "shipping right now" feed.
297///
298/// Public repositories only, and no drafts: this renders for anonymous
299/// visitors, so anything it can reach is world-readable by definition.
300///
301/// Driven by the event log rather than by `changes.updated_at`, so each row can
302/// state what happened. The join to `changes` is an inner join on
303/// `subject_type = 'change'`, which also drops repository- and bookmark-scoped
304/// events — the feed is about work, and "somebody renamed a bookmark" is not
305/// what a visitor came to see.
306async fn public_feed(state: &AppState) -> AppResult<Vec<FeedItem>> {
307 /// `(owner, repo, number, change_id, synthetic, title, actor, author_name,
308 /// kind, created_at)`
309 type Row = (
310 String,
311 String,
312 i64,
313 String,
314 bool,
315 String,
316 Option<String>,
317 Option<String>,
318 String,
319 DateTime<Utc>,
320 );
321
322 let rows: Vec<Row> = sqlx::query_as(
323 // `hr.author_name` is the fallback when no account matched the commit's
324 // email — the person is still known, just not linkable.
325 r#"
326 SELECT COALESCE(ou.handle, og.handle) AS owner,
327 r.name::text,
328 c.number,
329 c.change_id,
330 c.synthetic,
331 c.title,
332 ac.handle AS actor,
333 hr.author_name,
334 e.kind,
335 e.created_at
336 FROM events e
337 JOIN changes c ON c.id = e.subject_id AND e.subject_type = 'change'
338 JOIN repos r ON r.id = e.repo_id
339 LEFT JOIN users ou ON ou.id = r.owner_user_id
340 LEFT JOIN orgs og ON og.id = r.owner_org_id
341 LEFT JOIN users ac ON ac.id = e.actor_id
342 LEFT JOIN revisions hr ON hr.id = c.head_revision_id
343 WHERE r.archived = false
344 AND r.visibility = 'public'
345 AND c.state <> 'draft'
346 ORDER BY e.created_at DESC
347 LIMIT 12
348 "#,
349 )
350 .fetch_all(&state.db)
351 .await?;
352
353 Ok(rows
354 .into_iter()
355 .map(
356 |(owner, repo, number, change_id, synthetic, title, actor, actor_name, kind, when)| {
357 FeedItem {
358 owner,
359 repo,
360 number,
361 change_id,
362 synthetic,
363 title,
364 actor,
365 actor_name,
366 kind,
367 when,
368 }
369 },
370 )
371 .collect())
372}
373
374type ChangeRow = (
375 String,
376 String,
377 i64,
378 String,
379 bool,
380 String,
381 String,
382 bool,
383 Option<String>,
384 Option<String>,
385 DateTime<Utc>,
386);
387
388fn to_dash_changes(rows: Vec<ChangeRow>) -> Vec<DashChange> {
389 rows.into_iter()
390 .map(
391 |(
392 owner,
393 repo,
394 number,
395 change_id,
396 synthetic,
397 title,
398 state,
399 conflicted,
400 author,
401 author_name,
402 updated_at,
403 )| {
404 DashChange {
405 owner,
406 repo,
407 number,
408 change_id,
409 synthetic,
410 title,
411 state,
412 conflicted,
413 author,
414 author_name,
415 updated_at,
416 }
417 },
418 )
419 .collect()
420}
421
422const CHANGE_COLUMNS: &str = r#"
423 SELECT COALESCE(ou.handle, og.handle) AS owner,
424 r.name::text,
425 c.number,
426 c.change_id,
427 c.synthetic,
428 c.title,
429 c.state::text,
430 c.conflicted,
431 au.handle AS author,
432 hr.author_name,
433 c.updated_at
434 FROM changes c
435 JOIN repos r ON r.id = c.repo_id
436 LEFT JOIN users ou ON ou.id = r.owner_user_id
437 LEFT JOIN orgs og ON og.id = r.owner_org_id
438 LEFT JOIN users au ON au.id = c.author_user_id
439 LEFT JOIN revisions hr ON hr.id = c.head_revision_id
440"#;
441
442/// Open changes waiting on this viewer.
443///
444/// There is no "requested reviewers" table — `reviews` records verdicts that
445/// were *given*, not ones that were asked for. So "awaiting your review" is
446/// derived: an open change in a repo you can reach, that you did not write, and
447/// that you have not reviewed at its current head revision. Re-reviewing is
448/// therefore prompted whenever the author pushes again, which is the behaviour
449/// a reviewer wants.
450async fn awaiting_review(
451 state: &AppState,
452 user: &df_db::models::User,
453) -> AppResult<Vec<DashChange>> {
454 let sql = format!(
455 r#"
456 {CHANGE_COLUMNS}
457 WHERE {VISIBLE_TO_VIEWER}
458 AND c.state = 'open'
459 AND c.author_user_id IS DISTINCT FROM $1
460 AND NOT EXISTS (
461 SELECT 1 FROM reviews rv
462 WHERE rv.change_id_fk = c.id
463 AND rv.reviewer_id = $1
464 AND rv.revision_id = c.head_revision_id
465 )
466 ORDER BY c.updated_at DESC
467 LIMIT 10
468 "#
469 );
470
471 let rows: Vec<ChangeRow> = sqlx::query_as(&sql)
472 .bind(user.id)
473 .bind(user.is_admin)
474 .fetch_all(&state.db)
475 .await?;
476
477 Ok(to_dash_changes(rows))
478}
479
480async fn my_open_changes(
481 state: &AppState,
482 user: &df_db::models::User,
483) -> AppResult<Vec<DashChange>> {
484 let sql = format!(
485 r#"
486 {CHANGE_COLUMNS}
487 WHERE {VISIBLE_TO_VIEWER}
488 AND c.author_user_id = $1
489 AND c.state IN ('open', 'draft')
490 ORDER BY c.updated_at DESC
491 LIMIT 10
492 "#
493 );
494
495 let rows: Vec<ChangeRow> = sqlx::query_as(&sql)
496 .bind(user.id)
497 .bind(user.is_admin)
498 .fetch_all(&state.db)
499 .await?;
500
501 Ok(to_dash_changes(rows))
502}
503
504/// Recent events across every repository the viewer can reach.
505async fn watched_activity(
506 state: &AppState,
507 user: &df_db::models::User,
508) -> AppResult<Vec<ActivityItem>> {
509 let sql = format!(
510 r#"
511 SELECT COALESCE(ou.handle, og.handle) AS owner,
512 r.name::text,
513 ac.handle AS actor,
514 e.kind,
515 ch.number,
516 ch.title,
517 e.created_at
518 FROM events e
519 JOIN repos r ON r.id = e.repo_id
520 LEFT JOIN users ou ON ou.id = r.owner_user_id
521 LEFT JOIN orgs og ON og.id = r.owner_org_id
522 LEFT JOIN users ac ON ac.id = e.actor_id
523 LEFT JOIN changes ch
524 ON ch.id = e.subject_id AND e.subject_type = 'change'
525 WHERE {VISIBLE_TO_VIEWER}
526 ORDER BY e.created_at DESC
527 LIMIT 15
528 "#
529 );
530
531 let rows: Vec<(
532 String,
533 String,
534 Option<String>,
535 String,
536 Option<i64>,
537 Option<String>,
538 DateTime<Utc>,
539 )> = sqlx::query_as(&sql)
540 .bind(user.id)
541 .bind(user.is_admin)
542 .fetch_all(&state.db)
543 .await?;
544
545 Ok(rows
546 .into_iter()
547 .map(
548 |(owner, repo, actor, kind, change_number, change_title, when)| ActivityItem {
549 owner,
550 repo,
551 actor,
552 kind,
553 change_number,
554 change_title,
555 when,
556 },
557 )
558 .collect())
559}

559 lines · Rust