steid

@jamesgill /

steid/src/infrastructure/web/file_history.rs
Rust8.5 KBCode·Blame·History·Raw
1//! One path's history — `/{handle}/repos/{name}/log/{rev}/-/{*path}`.
2//!
3//! The commit log narrowed to a file or a directory, in the log's own rows so the two
4//! read as one list seen at two widths. The URL is the log's with the tree's `/-/`
5//! separator on the end, which means a reader editing `tree` to `log` in the address
6//! bar lands here — the same trick blame's URL plays.
7//!
8//! **A file's history is followed through renames.** `git log --follow` is what makes
9//! the page worth having: a file's history that begins at the moment it was renamed is
10//! the history of the name, not of the file. A directory cannot be followed — git
11//! refuses — and does not ask to be.
12
13use topcoat::{
14 Result,
15 context::Cx,
16 icon::{icon, iconify::iconify_icon},
17 router::{error::not_found, page, path_param, query_params},
18 view::{attributes, component, view},
19};
20
21use crate::{
22 application::{PathLog, Stores, repo_path_log},
23 domain::{RefName, RepoPath},
24};
25
26use super::{
27 blame::{FileTab, view_toggle},
28 browse::{
29 PageQuery, Switch, commit_log, crumbs, is_markdown, ref_links, refs_for, rev_switcher,
30 },
31 context::{current_actor, memberships, orgs, queries, repos, server_error, timed_out},
32 layout::wide,
33 paging::{Pages, list_pages, requested_page, showing_at_most},
34 repo::{Tab, repo_for, repo_header},
35 urls::{log_url, path_log_url, tree_url},
36};
37
38/// `{rev}` from the path, raw — validation is [`RefName`]'s job, as on every other
39/// browsing route.
40#[path_param]
41struct Rev(str);
42
43/// `{*path}` from the path: every remaining segment, as one string.
44#[path_param]
45struct Path(str);
46
47#[page("/{handle}/repos/{name}/log/{rev}/-/{*path}")]
48async fn file_history_page(cx: &Cx) -> Result {
49 let rev = RefName::new(path_param::<Rev>(cx)).map_err(|_| not_found())?;
50 let path = RepoPath::new(path_param::<Path>(cx)).map_err(|_| not_found())?;
51
52 view! { history_of(rev: rev, path: path) }
53}
54
55/// The page. A component rather than a plain function because `view!` needs the request
56/// context in scope.
57#[component]
58async fn history_of(cx: &Cx, rev: RefName, path: RepoPath) -> Result {
59 let repo = repo_for(cx).await?;
60
61 let outcome = repo_path_log(
62 &repo.handle,
63 &repo.name,
64 Some(&rev),
65 &path,
66 requested_page(query_params::<PageQuery>(cx)?.page.as_deref()),
67 &current_actor(cx).await?,
68 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
69 &queries(cx),
70 )
71 .await;
72
73 // A walk that ran out of time is a statement about *this path* — a long history,
74 // or one `--follow` had to read every tree of — and not about the instance being
75 // broken, so it is a state rather than a 500. Blame answers the same way.
76 let log: Option<PathLog> = match outcome {
77 Ok(Some(log)) => Some(log),
78 Ok(None) => return Err(not_found().into()),
79 Err(error) if timed_out(&error) => None,
80 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
81 };
82
83 let handle = repo.handle.as_str();
84 let name = repo.name.as_str();
85 let at = rev.as_str();
86
87 let refs = refs_for(cx, &repo).await?;
88 let known = refs.contains(&rev);
89 let switch = Switch::PathLog(&path);
90 let branches = ref_links(handle, name, &refs.branches, at, &switch);
91 let tags = ref_links(handle, name, &refs.tags, at, &switch);
92
93 // Built out here rather than inside the view: each borrows a formatted string, and
94 // a temporary made inside a `view!` branch does not outlive it.
95 let sentence = match &log {
96 Some(log) => showing_at_most(
97 log.commits.offset(),
98 log.commits.items.len(),
99 log.commits.total,
100 log.capped,
101 "commits",
102 ),
103 None => String::new(),
104 };
105 let pages = match &log {
106 Some(log) => Pages::numbered(log.commits.page, log.commits.pages, |page| {
107 format!("{}?page={page}", path_log_url(handle, name, &rev, &path))
108 }),
109 None => Pages::numbered(1, 1, |page| page.to_string()),
110 };
111
112 view! {
113 wide(
114 repo_header(
115 repo: &repo,
116 rev: at,
117 active: Tab::Commits,
118 rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
119 )
120
121 // The path, above the list rather than in the panel: the rows below are the
122 // log's own and gain nothing from a frame, so the breadcrumb is what says
123 // whose history this is.
124 <div class="mb-3 flex flex-wrap items-center justify-between gap-3">
125 crumbs(handle: handle, name: name, rev: &rev, path: &path)
126 <span class="flex items-center gap-3 font-mono text-xs text-muted-foreground">
127 match &log {
128 // A directory has no blame and no raw bytes, so it gets the one
129 // link that means anything: back to the listing.
130 Some(log) if !log.is_file => back_to_tree(
131 handle: handle,
132 name: name,
133 rev: &rev,
134 path: &path,
135 ),
136 _ => view_toggle(
137 handle: handle,
138 name: name,
139 rev: &rev,
140 path: &path,
141 active: FileTab::History,
142 markdown: is_markdown(&path),
143 ),
144 }
145 </span>
146 </div>
147
148 match &log {
149 Some(log) => {
150 // Said once, above the list, and only where it is true: a directory's
151 // history is not followed and claiming otherwise would be a lie about
152 // what the rows are.
153 if log.is_file {
154 <p class="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
155 icon(data: iconify_icon!("feather:corner-right-down"), attrs: attributes! {
156 class="size-3.5 shrink-0"
157 })
158 "Renames are followed, so commits from before this file had its "
159 "current name are included."
160 </p>
161 }
162
163 // Said where the number is, because the number is what it qualifies:
164 // a followed walk stops at 500, and a reader deciding whether they
165 // have seen the whole history needs to know it did.
166 if log.capped {
167 <p class="mb-2 text-xs text-muted-foreground">
168 "Only the most recent 500 commits are counted here — "
169 "following renames reads a tree per commit, and a longer "
170 "walk is one that times out."
171 </p>
172 }
173
174 commit_log(handle: handle, name: name, commits: &log.commits.items)
175 list_pages(summary: sentence.as_str(), pages: &pages)
176 },
177 None => took_too_long(handle: handle, name: name, rev: &rev),
178 }
179 )
180 }
181}
182
183/// The way back to the listing, for a directory's history.
184#[component]
185async fn back_to_tree(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> Result {
186 view! {
187 <a
188 href=(tree_url(handle, name, rev, path))
189 class="text-muted-foreground hover:text-foreground"
190 >"Code"</a>
191 }
192}
193
194/// What a history that could not be walked in time says.
195///
196/// It offers the whole log rather than a retry: the same walk would take just as long
197/// again, and the unnarrowed log is the cheaper question that still shows history.
198#[component]
199async fn took_too_long(handle: &str, name: &str, rev: &RefName) -> Result {
200 view! {
201 <div class="rounded-lg border border-border px-4 py-10 text-center">
202 <p class="text-sm text-muted-foreground">
203 "Walking this path's history took too long."
204 </p>
205 <p class="mt-1 text-xs text-muted-foreground">
206 "Every commit has to be examined to see whether this path changed in it. "
207 <a
208 href=(log_url(handle, name, rev.as_str()))
209 class="text-primary hover:underline"
210 >"Read the whole log instead"</a>
211 "."
212 </p>
213 </div>
214 }
215}