feat(repo-browse): preview PDFs page-by-page and open markdown as prose
The browser cannot render a blob: PDF inside our frame, so the server rasterises with hayro and streams self-describing frames (u32 len | JSON header | u32 len | PNG) from GET /html/blob-pages: the first page paints immediately, a shallow channel stops the renderer when the reader leaves, 96 MB of pages are cached per process, and 400 pages is the ceiling. LFS-backed blobs resolve through storage. An opened .md now shares the README sandbox frame via prepareMarkdownHtml, with a preview/source toggle; the frame height protocol no longer feeds its own reported number back into documentElement and ratchets no more.
ZhenYi committed
Added lib/git/src/handler/html/blob_pages.rs lib/git/src/handler/html/blob_pages.rs +92 −0 View lib/git/src/handler/html/blob_pages.rs
@@ -0,0 +1,92 @@
1use axum::body::Body;
2use axum::extract::{Path, Query, State};
3use axum::http::{StatusCode, header};
4use axum::response::Response;
5
6use crate::handler::html::error::html_err;
7use crate::handler::html::{HtmlRepoPath, resolve_view};
8use crate::service::GitService;
9use crate::service::pdf;
10use crate::share::access::BasicAuth;
11
12#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
13pub struct BlobPagesQuery {
14 #[serde(default)]
15 pub revision: Option<String>,
16 #[serde(default)]
17 pub path: Option<String>,
18 /// Rasterisation scale: 1.0 is 72 dpi. Clamped to 0.5..=3.0.
19 #[serde(default)]
20 pub scale: Option<f32>,
21}
22
23/// Rasterised pages of a PDF, streamed one page at a time.
24///
25/// Framing: repeating `u32 header_len | header | u32 payload_len | payload`,
26/// all big-endian. The header is JSON — `{page,total,width,height}` per page,
27/// `{page,total,error}` for a page that failed, `{total,done}` to close — and
28/// the payload of a page frame is its PNG bytes. Streaming (rather than one
29/// request per page) is what keeps the first page instant while the rest of a
30/// long document is still being rasterised.
31#[utoipa::path(
32 get,
33 path = "/repos/{space_name}/{repo_name}/html/blob-pages",
34 tag = "html",
35 description = "按页栅格化 PDF 并以分帧流式返回 PNG,供文件页阅读器使用。",
36 operation_id = "render_blob_pages",
37 params(HtmlRepoPath, BlobPagesQuery),
38 responses(
39 (status = 200, description = "framed png page stream"),
40 (status = 404, description = "repo, ref or blob not found"),
41 (status = 400, description = "blob is not a readable pdf")
42 )
43)]
44pub async fn render_blob_pages(
45 State(service): State<GitService>,
46 Path(path): Path<HtmlRepoPath>,
47 Query(q): Query<BlobPagesQuery>,
48 auth: BasicAuth,
49) -> Response {
50 let view = match resolve_view(service.clone(), path, auth, q.revision, q.path, true).await {
51 Ok(v) => v,
52 Err(resp) => return resp,
53 };
54 let ctx = view.ctx;
55 let scale = pdf::clamp_scale(q.scale);
56 let receiver = match pdf::render_stream(
57 &service,
58 &ctx.space_name,
59 &ctx.repo_name,
60 &view.revision,
61 &view.clean_path,
62 scale,
63 )
64 .await
65 {
66 Ok(rx) => rx,
67 Err(crate::share::errors::GitError::NotFound) => return html_err(404, "blob not found"),
68 Err(e) => {
69 tracing::warn!(error = %e, "pdf page stream rejected");
70 return html_err(400, "blob is not a readable pdf");
71 }
72 };
73
74 let stream = futures::stream::unfold(receiver, |mut rx| async move {
75 rx.recv()
76 .await
77 .map(|chunk| (Ok::<Vec<u8>, std::io::Error>(chunk), rx))
78 });
79 let mut resp = Response::new(Body::from_stream(stream));
80 *resp.status_mut() = StatusCode::OK;
81 let headers = resp.headers_mut();
82 headers.insert(
83 header::CONTENT_TYPE,
84 "application/octet-stream".parse().unwrap(),
85 );
86 headers.insert(header::CACHE_CONTROL, "private, no-store".parse().unwrap());
87 headers.insert(header::VARY, "Cookie".parse().unwrap());
88 // Reverse proxies must hand each frame through as it is produced, otherwise
89 // the streaming reader degrades into a spinner until the document ends.
90 headers.insert("x-accel-buffering", "no".parse().unwrap());
91 resp
92}
Modified lib/git/src/handler/html/mod.rs lib/git/src/handler/html/mod.rs +5 −0 View lib/git/src/handler/html/mod.rs
@@ -18,6 +18,7 @@
1818pub mod blame;
1919pub mod blob_content;
2020pub mod blob_header;
21pub mod blob_pages;
2122pub mod breadcrumbs;
2223pub mod clone_buttons;
2324pub mod commit_diff;
@@ -92,6 +93,10 @@
9293 "/blob-content",
9394 axum::routing::get(blob_content::render_blob_content),
9495 )
96 .route(
97 "/blob-pages",
98 axum::routing::get(blob_pages::render_blob_pages),
99 )
95100 .route("/readme", axum::routing::get(readme::render_readme))
96101 .route("/commits", axum::routing::get(commits::render_commits))
97102 .route(
Modified lib/git/src/service/mod.rs lib/git/src/service/mod.rs +1 −0 View lib/git/src/service/mod.rs
@@ -54,6 +54,7 @@
5454pub mod packs;
5555pub mod patches;
5656pub mod path_log;
57pub mod pdf;
5758pub mod protection;
5859pub mod refs;
5960pub mod releases;
Added lib/git/src/service/pdf.rs lib/git/src/service/pdf.rs +342 −0 View lib/git/src/service/pdf.rs
@@ -0,0 +1,342 @@
1use std::collections::{HashMap, VecDeque};
2use std::sync::{Arc, Mutex, OnceLock};
3
4use hayro::hayro_interpret::InterpreterSettings;
5use hayro::hayro_syntax::Pdf;
6use hayro::{RenderCache, RenderSettings, render};
7use serde_json::json;
8use tokio::sync::mpsc;
9
10use gitbaby::commit::types::TreeEntryMode;
11use gitbaby::error::BabyError;
12
13use crate::lfs;
14use crate::service::GitService;
15use crate::service::blobs;
16use crate::share::errors::{GitError, map_baby};
17
18/// Page rasterisation for the in-repo PDF viewer.
19///
20/// The browser's own PDF surface cannot be reached from an embedded same-origin
21/// frame, so the server rasterises the document and streams it back one page at
22/// a time. Rendering is CPU-bound and runs on a blocking task; the channel is
23/// shallow, so a reader that stops consuming also stops the renderer instead of
24/// rasterising all 200+ pages for nothing.
25pub const PAGE_SCALE_MIN: f32 = 0.5;
26pub const PAGE_SCALE_MAX: f32 = 3.0;
27pub const PAGE_SCALE_DEFAULT: f32 = 2.0;
28/// Document ceiling: past this the stream ends early and says so.
29pub const PAGE_LIMIT: usize = 400;
30/// Frames the renderer may run ahead of the reader.
31const CHANNEL_DEPTH: usize = 2;
32/// Rasterised pages kept in the process for repeat views, shared by every
33/// viewer of the same blob and scale.
34const CACHE_BUDGET_BYTES: usize = 96 * 1024 * 1024;
35
36pub fn clamp_scale(scale: Option<f32>) -> f32 {
37 match scale {
38 Some(v) if v.is_finite() => v.clamp(PAGE_SCALE_MIN, PAGE_SCALE_MAX),
39 _ => PAGE_SCALE_DEFAULT,
40 }
41}
42
43/// Frame the payload as `u32 header_len | header | u32 payload_len | payload`,
44/// all big-endian. The header is JSON tagged by `kind`:
45/// `doc` (once, upfront), `page` (PNG payload), `page_error`, `done`. Frames are
46/// self-describing, so a single failed page can be reported inline instead of
47/// killing the stream, and the reader needs no lookahead beyond the two lengths.
48fn encode_frame(header: &str, payload: &[u8]) -> Vec<u8> {
49 let mut out = Vec::with_capacity(header.len() + payload.len() + 8);
50 out.extend_from_slice(&(header.len() as u32).to_be_bytes());
51 out.extend_from_slice(header.as_bytes());
52 out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
53 out.extend_from_slice(payload);
54 out
55}
56
57fn frame(value: serde_json::Value, payload: &[u8]) -> Vec<u8> {
58 encode_frame(&value.to_string(), payload)
59}
60
61struct CachedPage {
62 png: Arc<Vec<u8>>,
63 width: u16,
64 height: u16,
65}
66
67#[derive(Default)]
68struct PageCache {
69 pages: HashMap<String, CachedPage>,
70 order: VecDeque<String>,
71 bytes: usize,
72}
73
74fn page_cache() -> &'static Mutex<PageCache> {
75 static CACHE: OnceLock<Mutex<PageCache>> = OnceLock::new();
76 CACHE.get_or_init(|| Mutex::new(PageCache::default()))
77}
78
79fn cache_get(key: &str) -> Option<(Arc<Vec<u8>>, u16, u16)> {
80 let cache = page_cache().lock().ok()?;
81 let hit = cache.pages.get(key)?;
82 Some((hit.png.clone(), hit.width, hit.height))
83}
84
85fn cache_put(key: String, png: Arc<Vec<u8>>, width: u16, height: u16) {
86 if png.len() > CACHE_BUDGET_BYTES {
87 return;
88 }
89 let Ok(mut cache) = page_cache().lock() else {
90 return;
91 };
92 if let Some(prev) = cache.pages.remove(&key) {
93 cache.bytes = cache.bytes.saturating_sub(prev.png.len());
94 cache.order.retain(|k| k != &key);
95 }
96 while cache.bytes + png.len() > CACHE_BUDGET_BYTES {
97 let Some(oldest) = cache.order.pop_front() else {
98 break;
99 };
100 if let Some(evicted) = cache.pages.remove(&oldest) {
101 cache.bytes = cache.bytes.saturating_sub(evicted.png.len());
102 }
103 }
104 cache.bytes += png.len();
105 cache.order.push_back(key.clone());
106 cache.pages.insert(key, CachedPage { png, width, height });
107}
108
109/// gitbaby resolves refs through `git`, so an unknown revision surfaces as a
110/// non-zero exit rather than a typed error. On this read path the only thing
111/// that matters is the resulting status: an unresolvable ref is a 404, exactly
112/// like a missing path.
113fn is_missing_ref(error: &BabyError) -> bool {
114 match error {
115 BabyError::RevisionNotFound(_)
116 | BabyError::RevisionInvalid(_)
117 | BabyError::RefNotFound(_)
118 | BabyError::PathNotFound { .. } => true,
119 BabyError::Command(cmd) => has_missing_ref_text(&cmd.to_string()),
120 // gitbaby only classifiers the `git` message shapes it knows; anything
121 // else (such as `fatal: Not a valid object name`) lands here, so the
122 // stderr has to be inspected once more.
123 BabyError::Spawn { stderr, .. } => has_missing_ref_text(stderr),
124 _ => false,
125 }
126}
127
128fn has_missing_ref_text(text: &str) -> bool {
129 let text = text.to_ascii_lowercase();
130 text.contains("not a valid object name")
131 || text.contains("unknown revision")
132 || text.contains("bad revision")
133 || text.contains("does not exist")
134 || text.contains("needed a single revision")
135}
136
137/// Where the bytes of a previewable file actually live, and under which cache
138/// key they belong.
139struct Source {
140 bytes: Vec<u8>,
141 /// Content-addressed identity: the git blob id, or the LFS object id. A
142 /// branch that moves or a tag that is rewritten can therefore never replay
143 /// an older file's pages.
144 key: String,
145}
146
147/// Whole bytes for a PDF, resolving through Git LFS when the blob is a pointer.
148///
149/// A pointer blob is ~130 bytes of text, so an LFS-tracked `paper.pdf` must be
150/// followed to object storage or the rasteriser only ever sees the pointer. A
151/// partial file cannot be parsed either way, so the size cap rejects the
152/// document instead of truncating it.
153async fn pdf_bytes(
154 service: &GitService,
155 space: &str,
156 repo: &str,
157 revision: &str,
158 path: &str,
159) -> Result<Source, GitError> {
160 let ctx = service.context(space.to_string(), repo.to_string()).await?;
161 let baby = service.baby(&ctx);
162 // A missing ref, a missing path and a directory all read as not-found here;
163 // only a file that really is not a readable PDF is a bad request.
164 let entry = match baby
165 .tree_entry_at(revision, std::path::Path::new(path))
166 .await
167 {
168 Ok(Some(entry)) => entry,
169 Ok(None) => return Err(GitError::NotFound),
170 Err(e) if is_missing_ref(&e) => return Err(GitError::NotFound),
171 Err(e) => return Err(map_baby(e)),
172 };
173 if !matches!(
174 entry.mode,
175 TreeEntryMode::Blob | TreeEntryMode::Exec | TreeEntryMode::Symlink
176 ) {
177 return Err(GitError::NotFound);
178 }
179
180 let max = service.state.config.git.max_blob_size;
181 let size = baby.get_blob_size(entry.oid).await.map_err(map_baby)?;
182
183 // Sniff before reading the whole file: `parse_lfs_pointer` only needs its
184 // first lines, and a plain multi-hundred-megabyte PDF should be rejected by
185 // the size cap rather than paged into memory first.
186 let sniff = baby
187 .get_blob(
188 entry.oid,
189 gitbaby::blob::types::GetBlobOptions {
190 limit: Some(blobs::SNIFF_LIMIT),
191 },
192 )
193 .await
194 .map_err(map_baby)?;
195 if let Some(pointer) = blobs::parse_lfs_pointer(&sniff.data) {
196 return lfs_source(service, &ctx, space, repo, pointer).await;
197 }
198
199 if size.max(0) as u64 > max {
200 return Err(GitError::InvalidArgument(format!(
201 "pdf blob is {size} bytes, above the {max} byte preview limit"
202 )));
203 }
204 let blob = baby
205 .get_blob(
206 entry.oid,
207 gitbaby::blob::types::GetBlobOptions { limit: None },
208 )
209 .await
210 .map_err(map_baby)?;
211 Ok(Source {
212 bytes: blob.data,
213 key: entry.oid.to_string(),
214 })
215}
216
217/// Follow one LFS pointer to object storage.
218async fn lfs_source(
219 service: &GitService,
220 ctx: &crate::context::facade::GitContext,
221 space: &str,
222 repo: &str,
223 pointer: blobs::LfsPointerDto,
224) -> Result<Source, GitError> {
225 if !ctx.model.enable_lfs {
226 return Err(GitError::InvalidArgument(
227 "file is an lfs pointer but lfs is disabled on this repo".to_string(),
228 ));
229 }
230 let raw = pointer
231 .oid
232 .ok_or_else(|| GitError::InvalidArgument("lfs pointer has no object id".to_string()))?;
233 let oid = lfs::canonical_oid(&raw)?;
234
235 let max = service.state.config.git.max_blob_size;
236 if let Some(size) = pointer.size
237 && size.max(0) as u64 > max
238 {
239 return Err(GitError::InvalidArgument(format!(
240 "lfs object is {size} bytes, above the {max} byte preview limit"
241 )));
242 }
243
244 let mut bytes = Vec::new();
245 let written = service
246 .lfs_download_to(space.to_string(), repo.to_string(), oid.clone(), &mut bytes)
247 .await?;
248 if written > max {
249 return Err(GitError::InvalidArgument(format!(
250 "lfs object is {written} bytes, above the {max} byte preview limit"
251 )));
252 }
253 Ok(Source { bytes, key: oid })
254}
255
256/// Stream `path` as framed pages. `Err` covers everything that has to fail
257/// before the response starts (missing blob, unreadable document).
258pub async fn render_stream(
259 service: &GitService,
260 space: &str,
261 repo: &str,
262 revision: &str,
263 path: &str,
264 scale: f32,
265) -> Result<mpsc::Receiver<Vec<u8>>, GitError> {
266 let source = pdf_bytes(service, space, repo, revision, path).await?;
267 let pdf = tokio::task::spawn_blocking(move || Pdf::new(source.bytes))
268 .await
269 .map_err(|e| GitError::Internal(format!("pdf task failed: {e}")))?
270 .map_err(|e| GitError::InvalidArgument(format!("unreadable pdf: {e:?}")))?;
271
272 let (tx, rx) = mpsc::channel::<Vec<u8>>(CHANNEL_DEPTH);
273 // Content-addressed cache key (git oid, or LFS object id): two paths that
274 // hold the same bytes share one render, and no revision name can replay an
275 // older file's pages.
276 let prefix = format!("pdf.{space}/{repo}/{}@{scale}", source.key);
277 tokio::task::spawn_blocking(move || {
278 let pages = pdf.pages();
279 let available = pages.len();
280 let total = available.min(PAGE_LIMIT);
281 let doc = json!({
282 "kind": "doc",
283 "pages": total,
284 "available": available,
285 "truncated": available > total,
286 });
287 if tx.blocking_send(frame(doc, &[])).is_err() {
288 return;
289 }
290
291 let settings = RenderSettings {
292 x_scale: scale,
293 y_scale: scale,
294 bg_color: hayro::vello_cpu::color::palette::css::WHITE,
295 ..Default::default()
296 };
297 let cache = RenderCache::new();
298 let interpreter = InterpreterSettings::default();
299 for index in 0..total {
300 let key = format!("{prefix}:{index}");
301 let (png, width, height) = match cache_get(&key) {
302 Some(hit) => hit,
303 None => {
304 let pixmap = render(&pages[index], &cache, &interpreter, &settings);
305 let (width, height) = (pixmap.width(), pixmap.height());
306 match pixmap.into_png() {
307 Ok(data) => {
308 let png = Arc::new(data);
309 cache_put(key, png.clone(), width, height);
310 (png, width, height)
311 }
312 Err(e) => {
313 let err = json!({
314 "kind": "page_error",
315 "page": index + 1,
316 "total": total,
317 "error": e.to_string(),
318 });
319 if tx.blocking_send(frame(err, &[])).is_err() {
320 return;
321 }
322 continue;
323 }
324 }
325 }
326 };
327 let header = json!({
328 "kind": "page",
329 "page": index + 1,
330 "total": total,
331 "width": width,
332 "height": height,
333 });
334 if tx.blocking_send(frame(header, &png)).is_err() {
335 return;
336 }
337 }
338 let done = json!({ "kind": "done", "pages": total });
339 let _ = tx.blocking_send(frame(done, &[]));
340 });
341 Ok(rx)
342}
Modified lib/git/Cargo.toml lib/git/Cargo.toml +1 −0 View lib/git/Cargo.toml
@@ -40,4 +40,5 @@
4040utoipa = { workspace = true }
4141maud = { workspace = true }
4242pulldown-cmark = { workspace = true }
43hayro = { workspace = true }
4344
Added web/src/features/repo-browse/model/blob-kind.ts web/src/features/repo-browse/model/blob-kind.ts +18 −0 View web/src/features/repo-browse/model/blob-kind.ts
@@ -0,0 +1,18 @@
1/**
2 * Which blob paths the code viewer can render as something richer than source
3 * lines. Kept separate from the markdown pipeline because the file page asks
4 * this question before it knows which renderer it will need.
5 */
6
7const MARKDOWN_RE = /\.(md|markdown)$/i
8const PDF_RE = /\.pdf$/i
9
10/** Markdown documents render as a formatted page, not numbered source. */
11export function isMarkdownPath(path: string): boolean {
12 return MARKDOWN_RE.test(path)
13}
14
15/** PDFs render in the browser's own viewer instead of the binary notice. */
16export function isPdfPath(path: string): boolean {
17 return PDF_RE.test(path)
18}
No newline at end of file
Added web/src/features/repo-browse/model/pdf-stream.ts web/src/features/repo-browse/model/pdf-stream.ts +137 −0 View web/src/features/repo-browse/model/pdf-stream.ts
@@ -0,0 +1,137 @@
1/**
2 * Framed page stream from `html/blob-pages`.
3 *
4 * The server rasterises a PDF page by page and pushes
5 * `u32 header_len | header | u32 payload_len | payload` frames, where the
6 * header is JSON tagged by `kind`: `doc`, `page`, `page_error`, `done`. Reading
7 * it incrementally is what makes a 200-page document open on page one — a
8 * buffered `fetch().then(r => r.arrayBuffer())` would hold the whole file.
9 */
10export interface PdfPage {
11 page: number
12 total: number
13 width: number
14 height: number
15 /** Object URL of the page PNG; owned by the caller. */
16 url: string
17}
18
19interface FrameHeader {
20 kind?: string
21 page?: number
22 total?: number
23 pages?: number
24 available?: number
25 truncated?: boolean
26 width?: number
27 height?: number
28 error?: string
29}
30
31export interface PdfStreamHandlers {
32 /** Total page count, known before the first page has been rasterised. */
33 onDocument?: (info: { pages: number; available: number; truncated: boolean }) => void
34 onPage: (page: PdfPage) => void
35 /** One page failed to rasterise; the stream continues. */
36 onPageError?: (info: { page: number; total: number; error: string }) => void
37}
38
39/**
40 * Rasterisation scale that makes a page fill `containerWidth` CSS pixels.
41 *
42 * One CSS pixel maps to `devicePixelRatio` device pixels, so that is the honest
43 * target; the ceiling keeps a long document from streaming hundreds of
44 * megabytes of PNG it would only downscale again.
45 */
46export function fitScale(containerWidth: number): number {
47 const dpr = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1
48 const target = (containerWidth * Math.min(dpr, 2)) / 595.28
49 return Math.min(2, Math.max(0.75, Math.round(target * 100) / 100))
50}
51
52/** Read a framed page stream, decoding headers as they arrive. */
53export async function readPdfStream(
54 url: string,
55 handlers: PdfStreamHandlers,
56 signal?: AbortSignal,
57): Promise<void> {
58 const res = await fetch(url, { signal, credentials: 'same-origin' })
59 if (!res.ok || !res.body) {
60 const err = new Error(`pdf stream failed: ${res.status}`) as Error & { status: number }
61 err.status = res.status
62 throw err
63 }
64 const reader = res.body.getReader()
65 let buffer: Uint8Array<ArrayBuffer> = new Uint8Array(0)
66 try {
67 for (;;) {
68 const { value, done } = await reader.read()
69 if (done) break
70 if (value) buffer = concat(buffer, new Uint8Array(value))
71 let offset = 0
72 for (;;) {
73 const head = readFrame(buffer, offset)
74 if (!head) break
75 offset = head.next
76 const header = head.header
77 if (header.kind === 'doc' && header.pages !== undefined) {
78 handlers.onDocument?.({
79 pages: header.pages,
80 available: header.available ?? header.pages,
81 truncated: header.truncated ?? false,
82 })
83 } else if (header.kind === 'page' && header.page) {
84 handlers.onPage({
85 page: header.page,
86 total: header.total ?? header.page,
87 width: header.width ?? 0,
88 height: header.height ?? 0,
89 url: URL.createObjectURL(new Blob([head.payload], { type: 'image/png' })),
90 })
91 } else if (header.kind === 'page_error' && header.page) {
92 handlers.onPageError?.({
93 page: header.page,
94 total: header.total ?? header.page,
95 error: header.error ?? 'render failed',
96 })
97 } else if (header.kind === 'done') {
98 return
99 }
100 }
101 if (offset > 0) buffer = buffer.slice(offset)
102 }
103 } finally {
104 reader.cancel().catch(() => {})
105 }
106}
107
108/** One decoded frame, or `null` while the buffer does not hold a complete one. */
109function readFrame(
110 buffer: Uint8Array<ArrayBuffer>,
111 offset: number,
112): { header: FrameHeader; payload: Uint8Array<ArrayBuffer>; next: number } | null {
113 if (buffer.length - offset < 8) return null
114 const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
115 const headLen = view.getUint32(offset)
116 const headEnd = offset + 4 + headLen
117 if (buffer.length < headEnd + 4) return null
118 const payloadLen = view.getUint32(headEnd)
119 const payloadStart = headEnd + 4
120 const next = payloadStart + payloadLen
121 if (buffer.length < next) return null
122 let header: FrameHeader
123 try {
124 header = JSON.parse(new TextDecoder().decode(buffer.subarray(offset + 4, headEnd))) as FrameHeader
125 } catch {
126 return null
127 }
128 return { header, payload: buffer.slice(payloadStart, next), next }
129}
130
131function concat(a: Uint8Array<ArrayBuffer>, b: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {
132 if (a.length === 0) return b
133 const out = new Uint8Array(a.length + b.length)
134 out.set(a, 0)
135 out.set(b, a.length)
136 return out
137}
No newline at end of file
Modified web/src/features/repo-browse/model/readme.ts web/src/features/repo-browse/model/readme.ts +25 −0 View web/src/features/repo-browse/model/readme.ts
@@ -13,6 +13,8 @@
1313 * raw-bytes endpoint, and a srcdoc iframe cannot attach our session cookie
1414 * to a relative request anyway.
1515 */
16import { marked } from 'marked'
17
1618import { fetchBlob, fetchTreeJson } from '@/entities/repo'
1719
1820/** Anything bigger is not worth base64-ing into an attribute. */
@@ -165,6 +167,7 @@
165167 return dataUrl
166168}
167169
170
168171export interface PrepareReadmeArgs {
169172 html: string
170173 space: string
@@ -218,3 +221,25 @@
218221
219222 return root?.innerHTML ?? ''
220223}
224
225export interface PrepareMarkdownArgs extends Omit<PrepareReadmeArgs, 'html'> {
226 /** Markdown source, i.e. the raw text of a .md / .markdown blob. */
227 text: string
228}
229
230/**
231 * Render markdown *source* (an opened .md file) through the same pipeline the
232 * server-rendered README uses: CommonMark → detached-document sanitise → inline
233 * repo-relative images. GFM + soft line breaks match how forge UIs render a
234 * README, so the two entry points agree on the same document.
235 */
236export async function prepareMarkdownHtml({
237 text,
238 space,
239 repo,
240 rev,
241 baseDir,
242}: PrepareMarkdownArgs): Promise<string> {
243 const html = marked.parse(text, { gfm: true, breaks: true, async: false })
244 return prepareReadmeHtml({ html, space, repo, rev, baseDir })
245}
Modified web/src/features/repo-browse/ui/code-tab.tsx web/src/features/repo-browse/ui/code-tab.tsx +188 −56 View web/src/features/repo-browse/ui/code-tab.tsx
@@ -1,5 +1,5 @@
11import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2import { ArrowLeft, ArrowUp, ChevronRight, File as FileIcon, Folder, FolderGit2, History, Users } from 'lucide-react'
2import { ArrowLeft, ArrowUp, ChevronRight, Eye, File as FileIcon, FileCode, Folder, FolderGit2, History, Users } from 'lucide-react'
33import { useT } from '@/shared/i18n'
44import { formatBytes, repoKey, useAsync } from '@/shared/lib'
55import { ErrorPanel, PanelNote } from '@/shared/ui'
@@ -13,9 +13,11 @@
1313 useHtmlFragment,
1414 type RepoDetail,
1515} from '@/entities/repo'
16import { prepareReadmeHtml, resolveRepoPath, statRepoPath } from '../model/readme'
16import { isMarkdownPath, isPdfPath } from '../model/blob-kind'
17import { prepareMarkdownHtml, prepareReadmeHtml, resolveRepoPath, statRepoPath } from '../model/readme'
1718import { useFragmentNavigation, type FragmentRoutes } from '../model/use-fragment-navigation'
1819import { BlameView } from './blame-view'
20import { PdfViewer } from './pdf-viewer'
1921
2022export interface CodeTabProps {
2123 space: string
@@ -38,8 +40,6 @@
3840 onShowHistory: (path: string) => void
3941 /** Clone address box, rendered in the header row above the listing. */
4042 cloneSlot?: ReactNode
41 /** In-repo search box, centered in that same header row. */
42 searchSlot?: ReactNode
4343}
4444
4545export type FileRef = { path: string; oid?: string }
@@ -61,7 +61,6 @@
6161 onOpenCommit,
6262 onShowHistory,
6363 cloneSlot,
64 searchSlot,
6564}: CodeTabProps) {
6665 // Path and file arrive from the route: the URL is the only place this view
6766 // state exists, so a refresh, a back click or a pasted link rebuilds it.
@@ -93,7 +92,6 @@
9392 routes={routes}
9493 onBack={closeFile}
9594 cloneSlot={cloneSlot}
96 searchSlot={searchSlot}
9795 />
9896 )
9997 }
@@ -106,7 +104,6 @@
106104 mode={mode}
107105 routes={routes}
108106 cloneSlot={cloneSlot}
109 searchSlot={searchSlot}
110107 />
111108 )
112109}
@@ -121,7 +118,6 @@
121118 mode,
122119 routes,
123120 cloneSlot,
124 searchSlot,
125121}: {
126122 space: string
127123 repo: string
@@ -130,17 +126,15 @@
130126 mode: 'html' | 'json'
131127 routes: FragmentRoutes
132128 cloneSlot?: ReactNode
133 searchSlot?: ReactNode
134129}) {
135130 const { t } = useT()
136 const headerless = !cloneSlot && !searchSlot
131 const headerless = !cloneSlot
137132 return (
138133 <div className="repo-code-view">
139134 <CodeHeader
140135 path={path}
141136 onNavigate={routes.onNavigate}
142137 cloneSlot={cloneSlot}
143 searchSlot={searchSlot}
144138 breadcrumbOnly={headerless}
145139 />
146140 <div className="repo-tree-card">
@@ -183,22 +177,19 @@
183177 leaf,
184178 onNavigate,
185179 cloneSlot,
186 searchSlot,
187180 breadcrumbOnly = false,
188181}: {
189182 path: string
190183 leaf?: string
191184 onNavigate: (p: string) => void
192185 cloneSlot?: ReactNode
193 searchSlot?: ReactNode
194186 /** Keep the row alive when it carries nothing but the path. */
195187 breadcrumbOnly?: boolean
196188}) {
197 if (!cloneSlot && !searchSlot && !breadcrumbOnly) return null
189 if (!cloneSlot && !breadcrumbOnly) return null
198190 return (
199191 <div className="repo-code-header flex flex-wrap items-center gap-2.5">
200192 <Breadcrumbs path={path} leaf={leaf} onNavigate={onNavigate} className="repo-code-header-path" />
201 {searchSlot ? <div className="repo-code-header-search min-w-[180px]">{searchSlot}</div> : null}
202193 {cloneSlot ? <div className="repo-code-header-clone flex items-center">{cloneSlot}</div> : null}
203194 </div>
204195 )
@@ -399,11 +390,13 @@
399390(function () {
400391 var last = 0
401392 function contentHeight() {
402 var b = document.body, e = document.documentElement
403 return Math.ceil(Math.max(
404 b ? b.scrollHeight : 0, b ? b.offsetHeight : 0,
405 e ? e.scrollHeight : 0, e ? e.offsetHeight : 0
406 ))
393 // Only the body's own box: the host feeds the reported number back in as
394 // the frame height, so including any viewport-derived metric (documentElement
395 // scroll/offset height) would re-report the frame it just set and ratchet the
396 // frame up by the host's rounding padding on every observer tick.
397 var b = document.body
398 if (!b) return 0
399 return Math.ceil(Math.max(b.scrollHeight, b.offsetHeight, b.getBoundingClientRect().height))
407400 }
408401 function report() {
409402 var h = contentHeight()
@@ -456,8 +449,9 @@
456449 window.addEventListener('resize', report)
457450 if (window.ResizeObserver && document.body) {
458451 var ro = new ResizeObserver(report)
452 // Body only: watching the root element makes the loop above fire whenever
453 // the host resizes the frame, which it does in response to this very report.
459454 ro.observe(document.body)
460 ro.observe(document.documentElement)
461455 }
462456 document.addEventListener('load', function (event) {
463457 var tag = event.target && event.target.tagName
@@ -482,36 +476,26 @@
482476 return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>:root{${shellVarDeclarations()}}${embedCssText}${SANDBOX_CSS}</style></head><body class="repo-embed"><div class="markdown-body">${bodyHtml}</div><script>${SANDBOX_SCRIPT}</script></body></html>`
483477}
484478
485function HtmlReadme({
486 space,
487 repo,
488 rev,
489 path,
479/**
480 * Renders already-prepared markdown markup in an opaque-origin sandbox. Shared
481 * by the directory README and an opened `.md` file so both previews use one
482 * document shell, one height protocol and one link router.
483 */
484function MarkdownFrame({
485 html,
486 baseDir,
490487 routes,
488 title = 'README',
489 className = 'mt-5',
491490}: {
492 space: string
493 repo: string
494 rev: string
495 path: string
491 html: string
492 /** Directory the markup lives in — resolves its repo-relative links. */
493 baseDir: string
496494 routes: FragmentRoutes
495 title?: string
496 className?: string
497497}) {
498 const fragment = useHtmlFragment(htmlFragmentUrl(space, repo, 'readme', { revision: rev, path }))
499 // Keyed state (same trick as useHtmlFragment): a stale README from another
500 // directory must never flash while the new one is still being prepared.
501 const sourceKey = `${space}/${repo}@${rev}:${path}`
502 const [prepared, setPrepared] = useState<{ key: string; html: string } | null>(null)
503 useEffect(() => {
504 if (!fragment || !fragment.ok || !fragment.html) return
505 let active = true
506 prepareReadmeHtml({ html: fragment.html, space, repo, rev, baseDir: path }).then(html => {
507 if (active) setPrepared({ key: sourceKey, html })
508 })
509 return () => {
510 active = false
511 }
512 }, [fragment, space, repo, rev, path, sourceKey])
513 const html = prepared && prepared.key === sourceKey ? prepared.html : null
514 const doc = useMemo(() => (html ? readmeDocument(html) : null), [html])
498 const doc = useMemo(() => readmeDocument(html), [html])
515499 const frameRef = useRef<HTMLIFrameElement | null>(null)
516500 // No fixed height: the frame grows with its content and the repo page does
517501 // the scrolling, so the README never gets its own scrollbar.
@@ -529,10 +513,10 @@
529513 const next = Math.min(Math.max(Math.round(data.height as number) + 2, 80), 40000)
530514 setHeight(current => (current === next ? current : next))
531515 } else if (data.kind === 'link' && typeof data.href === 'string') {
532 const target = resolveRepoPath(path, data.href)
516 const target = resolveRepoPath(baseDir, data.href)
533517 if (target) {
534518 void (async () => {
535 const entry = await statRepoPath(space, repo, rev, target)
519 const entry = await statRepoPath(routes.space, routes.repo, routes.rev, target)
536520 if (entry.kind === 'blob') routes.onOpenFile({ path: target, oid: entry.oid })
537521 else if (entry.kind === 'tree') routes.onNavigate(target)
538522 })()
@@ -551,7 +535,7 @@
551535 }
552536 window.addEventListener('message', onMessage)
553537 return () => window.removeEventListener('message', onMessage)
554 }, [path, routes, space, repo, rev])
538 }, [baseDir, routes])
555539
556540 // Ask the frame to re-measure: layout can settle after the initial report
557541 // (late media, host scrollbar changing the frame width, window resize).
@@ -570,15 +554,120 @@
570554 return (
571555 <iframe
572556 ref={frameRef}
573 title="README"
557 title={title}
574558 sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
575 className="repo-readme mt-5 w-full rounded-xl border border-shell-hairline bg-shell-surface"
559 className={`repo-readme w-full rounded-xl border border-shell-hairline bg-shell-surface ${className}`}
576560 style={{ height }}
577561 srcDoc={doc}
578562 />
579563 )
580564}
581565
566/** Directory README: fetch the server-rendered fragment, then sandbox it. */
567function HtmlReadme({
568 space,
569 repo,
570 rev,
571 path,
572 routes,
573}: {
574 space: string
575 repo: string
576 rev: string
577 path: string
578 routes: FragmentRoutes
579}) {
580 const fragment = useHtmlFragment(htmlFragmentUrl(space, repo, 'readme', { revision: rev, path }))
581 // Keyed state (same trick as useHtmlFragment): a stale README from another
582 // directory must never flash while the new one is still being prepared.
583 const sourceKey = `${space}/${repo}@${rev}:${path}`
584 const [prepared, setPrepared] = useState<{ key: string; html: string } | null>(null)
585 useEffect(() => {
586 if (!fragment || !fragment.ok || !fragment.html) return
587 let active = true
588 prepareReadmeHtml({ html: fragment.html, space, repo, rev, baseDir: path }).then(html => {
589 if (active) setPrepared({ key: sourceKey, html })
590 })
591 return () => {
592 active = false
593 }
594 }, [fragment, space, repo, rev, path, sourceKey])
595 const html = prepared && prepared.key === sourceKey ? prepared.html : null
596 if (!html) return null
597 return <MarkdownFrame html={html} baseDir={path} routes={routes} />
598}
599
600/**
601 * Opened markdown *file* (`?file=README.md`). A markdown blob is a document, not
602 * source code: the raw text is rendered through the same pipeline as the README
603 * preview so the file page matches what the directory listing shows. Images and
604 * relative links resolve against the file's own directory.
605 */
606function HtmlMarkdownFile({
607 space,
608 repo,
609 rev,
610 path,
611 oid,
612 routes,
613 className,
614}: {
615 space: string
616 repo: string
617 rev: string
618 path: string
619 oid?: string
620 routes: FragmentRoutes
621 className?: string
622}) {
623 const { t } = useT()
624 const [retry, setRetry] = useState(0)
625 const state = useAsync(
626 async () => {
627 // A deep link (`?file=README.md`) carries no oid, so resolve one from the
628 // tree exactly like the README's own image inlining does.
629 const entry = oid ? { kind: 'blob', oid } : await statRepoPath(space, repo, rev, path)
630 if (entry.kind !== 'blob') return null
631 return fetchBlob(space, repo, entry.oid)
632 },
633 [space, repo, rev, path, oid, retry],
634 // Same shape and key as JsonBlob: the blob is content-addressed, so the two
635 // renderers can share one cached payload.
636 { cacheKey: oid ? repoKey(space, repo, `blob:${oid}`) : null },
637 )
638 const sourceKey = `${space}/${repo}@${rev}:${path}`
639 const [prepared, setPrepared] = useState<{ key: string; html: string } | null>(null)
640 useEffect(() => {
641 const blob = state.data
642 if (!blob || blob.is_binary) return
643 let active = true
644 prepareMarkdownHtml({
645 text: decodeBlob(blob.data),
646 space,
647 repo,
648 rev,
649 baseDir: parentPath(path),
650 }).then(html => {
651 if (active) setPrepared({ key: sourceKey, html })
652 })
653 return () => {
654 active = false
655 }
656 }, [state.data, space, repo, rev, path, sourceKey])
657 if (state.error) {
658 return <ErrorPanel status={(state.error as { code?: number }).code ?? 0} what={t('repo.file.what')} onRetry={() => setRetry(value => value + 1)} />
659 }
660 const html = prepared && prepared.key === sourceKey ? prepared.html : null
661 if (!html) {
662 // `undefined` is still loading; a resolved `null` means the path has no
663 // previewable blob (missing, or binary) — never leave the spinner running.
664 return state.data === undefined
665 ? <PanelNote text={t('repo.file.loading')} />
666 : <PanelNote text={t('repo.file.unavailable')} />
667 }
668 return <MarkdownFrame html={html} baseDir={parentPath(path)} routes={routes} title={path} className={className} />
669}
670
582671const SHELL_VARS = [
583672 '--shell-surface',
584673 '--shell-field',
@@ -627,7 +716,6 @@
627716 routes,
628717 onBack,
629718 cloneSlot,
630 searchSlot,
631719}: {
632720 space: string
633721 repo: string
@@ -638,10 +726,14 @@
638726 routes: FragmentRoutes
639727 onBack: () => void
640728 cloneSlot?: ReactNode
641 searchSlot?: ReactNode
642729}) {
643730 const { t } = useT()
644731 const [blame, setBlame] = useState(false)
732 // Markdown blobs open as a document, matching how the directory listing
733 // renders its README; the toggle still reaches the raw source when needed.
734 const [source, setSource] = useState(false)
735 const isMarkdown = isMarkdownPath(file.path)
736 const isPdf = isPdfPath(file.path)
645737 const dir = file.path.includes('/') ? file.path.slice(0, file.path.lastIndexOf('/')) : ''
646738 const leaf = file.path.includes('/') ? file.path.slice(file.path.lastIndexOf('/') + 1) : file.path
647739 return (
@@ -651,7 +743,6 @@
651743 leaf={leaf}
652744 onNavigate={routes.onNavigate}
653745 cloneSlot={cloneSlot}
654 searchSlot={searchSlot}
655746 />
656747 <div className="repo-file-toolbar flex flex-wrap items-center gap-2">
657748 <button
@@ -662,12 +753,49 @@
662753 <ArrowLeft className="h-4 w-4" aria-hidden /> {t('repo.file.backToList')}
663754 </button>
664755 <span className="min-w-0 break-all font-mono text-[13px] font-semibold">{file.path}</span>
756 {isMarkdown && !isPdf ? (
757 <div
758 role="group"
759 aria-label={t('repo.file.markdownView')}
760 className="flex shrink-0 items-center rounded-md bg-shell-field p-0.5"
761 >
762 <button
763 type="button"
764 aria-pressed={!source && !blame}
765 onClick={() => {
766 setSource(false)
767 setBlame(false)
768 }}
769 className={`flex items-center gap-1.5 rounded px-2.5 py-1 text-[12.5px] transition-colors ${
770 !source && !blame ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
771 }`}
772 >
773 <Eye className="h-3.5 w-3.5" aria-hidden /> {t('repo.file.preview')}
774 </button>
775 <button
776 type="button"
777 aria-pressed={source}
778 onClick={() => {
779 setSource(true)
780 setBlame(false)
781 }}
782 className={`flex items-center gap-1.5 rounded px-2.5 py-1 text-[12.5px] transition-colors ${
783 source ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
784 }`}
785 >
786 <FileCode className="h-3.5 w-3.5" aria-hidden /> {t('repo.file.source')}
787 </button>
788 </div>
789 ) : null}
665790 <button
666791 type="button"
667792 aria-pressed={blame}
668793 disabled={!file.oid}
669794 title={file.oid ? t('repo.file.blameHint') : t('repo.file.blameJsonHint')}
670 onClick={() => setBlame(value => !value)}
795 onClick={() => {
796 setBlame(value => !value)
797 setSource(true)
798 }}
671799 className="flex items-center gap-1.5 rounded-md bg-shell-field px-2.5 py-1 text-[12.5px] transition-colors hover:brightness-95 aria-pressed:bg-shell-active aria-pressed:font-semibold disabled:opacity-40"
672800 >
673801 <Users className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden /> blame
@@ -682,6 +810,10 @@
682810 </div>
683811 {blame && file.oid ? (
684812 <BlameView space={space} repo={repo} rev={rev} path={file.path} oid={file.oid} onOpenCommit={routes.onOpenCommit} />
813 ) : isPdf ? (
814 <PdfViewer space={space} repo={repo} rev={rev} path={file.path} />
815 ) : isMarkdown && !source ? (
816 <HtmlMarkdownFile space={space} repo={repo} rev={rev} path={file.path} oid={file.oid} routes={routes} className="mt-3" />
685817 ) : mode === 'html' ? (
686818 <HtmlBlob space={space} repo={repo} rev={rev} path={file.path} line={line} routes={routes} />
687819 ) : (
Added web/src/features/repo-browse/ui/pdf-viewer.tsx web/src/features/repo-browse/ui/pdf-viewer.tsx +300 −0 View web/src/features/repo-browse/ui/pdf-viewer.tsx
@@ -0,0 +1,300 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2import { ChevronLeft, ChevronRight, Minus, Plus } from 'lucide-react'
3import { useT } from '@/shared/i18n'
4import { htmlFragmentUrl } from '@/entities/repo'
5import { readPdfStream, fitScale, type PdfPage } from '../model/pdf-stream'
6
7/** One streaming request's worth of state; keyed so late frames are ignored. */
8interface PdfRun {
9 key: string
10 pages: Map<number, PdfPage>
11 failed: number[]
12 total: number
13 truncated: boolean
14 status: 'loading' | 'streaming' | 'done' | 'error'
15 httpError: number
16}
17
18const NO_PAGES: Map<number, PdfPage> = new Map()
19const NO_FAILURES: number[] = []
20
21/**
22 * Opened PDF (`?file=paper.pdf`).
23 *
24 * A `blob:`/`data:` frame never reaches the browser's PDF plugin and the plugin
25 * does not render inside a same-origin `<iframe>` either, so the pages are
26 * rasterised by the server and streamed in one at a time. Frames land in the
27 * list as they arrive, which is what makes a 200-page document readable after
28 * the first page instead of after the last.
29 */
30export function PdfViewer({
31 space,
32 repo,
33 rev,
34 path,
35 className,
36}: {
37 space: string
38 repo: string
39 rev: string
40 path: string
41 className?: string
42}) {
43 const { t } = useT()
44 const hostRef = useRef<HTMLDivElement | null>(null)
45 const scrollRef = useRef<HTMLDivElement | null>(null)
46 const [run, setRun] = useState<PdfRun | null>(null)
47 const [current, setCurrent] = useState(1)
48 const [zoom, setZoom] = useState(1)
49 const [width, setWidth] = useState(0)
50 const [retry, setRetry] = useState(0)
51 // Re-rasterising (zoom, resize) rebuilds the page list, so the page the
52 // reader was on has to survive the swap.
53 const pendingPage = useRef<number | null>(null)
54
55 // Pages of a superseded run are never rendered: their object URLs have been
56 // revoked, so keeping them on screen would flash broken images.
57 const runKey = width ? `${space}/${repo}@${rev}:${path}:${zoom}:${retry}` : ''
58 const active = run && run.key === runKey ? run : null
59 const pages = active?.pages ?? NO_PAGES
60 const failed = active?.failed ?? NO_FAILURES
61 const total = active?.total ?? 0
62 const truncated = active?.truncated ?? false
63 const status = active?.status ?? 'loading'
64 const httpError = active?.httpError ?? 0
65
66 // The rasterisation scale follows the viewport the reader is laid out in: a
67 // page rendered wide enough for the column stays sharp at the device pixel
68 // ratio without streaming pixels the layout would only downscale again.
69 //
70 // This measures the wrapper, not the scrollport: the scrollport gains and
71 // loses a scrollbar as pages stream in, and reacting to that would restart
72 // the whole stream for a 14px difference. The threshold absorbs the same
73 // jitter from nested scrollbars and sub-pixel rounding.
74 useEffect(() => {
75 const host = hostRef.current
76 if (!host) return
77 let applied = 0
78 let timer: ReturnType<typeof setTimeout> | undefined
79 const measure = () => {
80 clearTimeout(timer)
81 timer = setTimeout(() => {
82 const next = host.clientWidth
83 if (next <= 0 || Math.abs(next - applied) < 24) return
84 applied = next
85 setWidth(next)
86 }, 250)
87 }
88 measure()
89 const observer = new ResizeObserver(measure)
90 observer.observe(host)
91 return () => {
92 clearTimeout(timer)
93 observer.disconnect()
94 }
95 }, [])
96
97 // Page images are object URLs owned by this component: every run revokes its
98 // own URLs, so switching file, zoom level or retrying cannot leak them.
99 useEffect(() => {
100 if (!width) return
101 const url = htmlFragmentUrl(space, repo, 'blob-pages', {
102 revision: rev,
103 path,
104 scale: fitScale(width) * zoom,
105 })
106 const key = runKey
107 const controller = new AbortController()
108 const created: string[] = []
109 const fresh: PdfRun = {
110 key,
111 pages: new Map(),
112 failed: [],
113 total: 0,
114 truncated: false,
115 status: 'loading',
116 httpError: 0,
117 }
118 setRun(fresh)
119 const patch = (next: Partial<PdfRun>) =>
120 setRun(prev => (prev && prev.key === key ? { ...prev, ...next } : prev))
121 readPdfStream(
122 url,
123 {
124 onDocument: info => patch({ total: info.pages, truncated: info.truncated, status: 'streaming' }),
125 onPage: page => {
126 if (controller.signal.aborted) {
127 URL.revokeObjectURL(page.url)
128 return
129 }
130 created.push(page.url)
131 setRun(prev => {
132 if (!prev || prev.key !== key) return prev
133 return { ...prev, pages: new Map(prev.pages).set(page.page, page) }
134 })
135 },
136 onPageError: info =>
137 setRun(prev =>
138 prev && prev.key === key ? { ...prev, failed: [...prev.failed, info.page] } : prev,
139 ),
140 },
141 controller.signal,
142 ).then(
143 () => patch({ status: 'done' }),
144 (err: unknown) => {
145 if (controller.signal.aborted) return
146 patch({ httpError: (err as { status?: number }).status ?? 0, status: 'error' })
147 },
148 )
149 return () => {
150 controller.abort()
151 for (const url of created) URL.revokeObjectURL(url)
152 }
153 // runKey folds in every input the request depends on.
154 // eslint-disable-next-line react-hooks/exhaustive-deps
155 }, [runKey, width])
156
157 const order = useMemo(() => [...pages.values()].sort((a, b) => a.page - b.page), [pages])
158 const lastPage = order.length ? order[order.length - 1].page : 0
159
160 // A re-rasterisation restarts the list from page one; put the reader back on
161 // the page they were reading as soon as that page has arrived again.
162 useEffect(() => {
163 const wanted = pendingPage.current
164 if (wanted === null) return
165 const host = scrollRef.current
166 const target = host?.querySelector<HTMLElement>(`[data-pdf-page="${wanted}"]`)
167 if (!host || !target) return
168 host.scrollTop = target.offsetTop - 12
169 pendingPage.current = null
170 }, [order])
171
172 const zoomBy = useCallback((delta: number) => {
173 pendingPage.current = current
174 setZoom(value => Math.min(2.4, Math.max(0.6, Math.round((value + delta) * 10) / 10)))
175 }, [current])
176
177 // Follow the reading position rather than the last streamed page, so the
178 // counter cannot run ahead of the reader while frames are still arriving.
179 const onScroll = useCallback(() => {
180 const host = scrollRef.current
181 if (!host) return
182 const top = host.scrollTop
183 let nearest = order.length ? order[0].page : 1
184 for (const item of host.querySelectorAll<HTMLElement>('[data-pdf-page]')) {
185 if (item.offsetTop - 32 <= top) nearest = Number(item.dataset.pdfPage)
186 else break
187 }
188 setCurrent(nearest)
189 }, [order])
190
191 const goTo = useCallback((page: number) => {
192 const host = scrollRef.current
193 const target = host?.querySelector<HTMLElement>(`[data-pdf-page="${page}"]`)
194 if (!host || !target) return
195 host.scrollTo({ top: target.offsetTop - 12, behavior: 'smooth' })
196 setCurrent(page)
197 }, [])
198
199 if (status === 'error' && !order.length) {
200 return (
201 <div className={`rounded-xl border border-shell-hairline bg-shell-surface ${className ?? 'mt-3'}`}>
202 <p className="px-4 py-10 text-center text-[13px] text-shell-ink-muted">
203 {httpError === 400 ? t('repo.file.pdfUnreadable') : t('repo.file.pdfFailed')}
204 </p>
205 <div className="flex justify-center pb-6">
206 <button
207 type="button"
208 onClick={() => setRetry(value => value + 1)}
209 className="rounded-md border-2 border-shell-hairline bg-shell-field px-4 py-1.5 text-[13px] transition-colors hover:border-shell-ink"
210 >
211 {t('common.action.retry')}
212 </button>
213 </div>
214 </div>
215 )
216 }
217
218 return (
219 <div ref={hostRef} className={className ?? 'mt-3'}>
220 <div className="repo-pdf-toolbar">
221 <button
222 type="button"
223 aria-label={t('repo.file.pdfPrev')}
224 disabled={current <= 1}
225 onClick={() => goTo(current - 1)}
226 className="repo-pdf-step"
227 >
228 <ChevronLeft className="h-4 w-4" aria-hidden />
229 </button>
230 <span className="repo-pdf-count">
231 {t('repo.file.pdfCounter', { page: current, total: total || lastPage || 1 })}
232 </span>
233 <button
234 type="button"
235 aria-label={t('repo.file.pdfNext')}
236 disabled={current >= lastPage}
237 onClick={() => goTo(current + 1)}
238 className="repo-pdf-step"
239 >
240 <ChevronRight className="h-4 w-4" aria-hidden />
241 </button>
242 <span className="repo-pdf-zoom">
243 <button
244 type="button"
245 aria-label={t('repo.file.pdfZoomOut')}
246 disabled={zoom <= 0.6}
247 onClick={() => zoomBy(-0.2)}
248 className="repo-pdf-step"
249 >
250 <Minus className="h-4 w-4" aria-hidden />
251 </button>
252 <span className="repo-pdf-count">{Math.round(zoom * 100)}%</span>
253 <button
254 type="button"
255 aria-label={t('repo.file.pdfZoomIn')}
256 disabled={zoom >= 2.4}
257 onClick={() => zoomBy(0.2)}
258 className="repo-pdf-step"
259 >
260 <Plus className="h-4 w-4" aria-hidden />
261 </button>
262 </span>
263 </div>
264 <div
265 ref={scrollRef}
266 onScroll={onScroll}
267 className="repo-pdf-scroll"
268 tabIndex={0}
269 role="region"
270 aria-label={path}
271 >
272 {order.map(page => (
273 <figure key={page.page} data-pdf-page={page.page} className="repo-pdf-page">
274 <img
275 src={page.url}
276 width={page.width || undefined}
277 height={page.height || undefined}
278 alt={t('repo.file.pdfPageAlt', { page: page.page })}
279 decoding="async"
280 />
281 </figure>
282 ))}
283 {failed.map(page => (
284 <p key={`f${page}`} className="repo-pdf-note">
285 {t('repo.file.pdfPageFailed', { page })}
286 </p>
287 ))}
288 {status === 'loading' && !order.length ? (
289 <p className="repo-pdf-note">{t('repo.file.pdfLoading')}</p>
290 ) : null}
291 {status === 'streaming' && order.length ? (
292 <p className="repo-pdf-note">
293 {t('repo.file.pdfStreaming', { done: order.length, total: total || order.length })}
294 </p>
295 ) : null}
296 {truncated ? <p className="repo-pdf-note">{t('repo.file.pdfTruncated')}</p> : null}
297 </div>
298 </div>
299 )
300}
No newline at end of file
Modified web/src/features/repo-browse/index.ts web/src/features/repo-browse/index.ts +0 −1 View web/src/features/repo-browse/index.ts
@@ -2,4 +2,3 @@
22// Consumers import from '@/features/repo-browse', never from deeper files.
33export { CodeTab, type CodeTabProps } from './ui/code-tab'
44export { prefetchRepoView } from './model/prefetch'
5export { RepoCodeSearch } from './ui/repo-code-search'
Modified web/src/pages/workspace/ui/repo/repo-polish.css web/src/pages/workspace/ui/repo/repo-polish.css +17 −6 View web/src/pages/workspace/ui/repo/repo-polish.css
@@ -37,12 +37,10 @@
3737.repo-code-header-path { min-width: 0; flex: 1 1 110px; overflow: hidden; white-space: nowrap; }
3838.repo-code-header-path.repo-breadcrumbs { flex-wrap: nowrap; }
3939.repo-code-header-path button { white-space: nowrap; }
40.repo-code-header-search { display: flex; flex: 1 1 190px; min-width: 150px; align-items: center; }
41.repo-code-header-search > * { width: 100%; }
42/* Breadcrumbs + search share the first row while the column can hold them at a
43 usable width; the clone box drops to a row of its own as soon as it cannot
44 join them without squeezing the search into a sliver. Everything grows, so
45 whichever row it lands on is filled to the edge instead of leaving a gap. */
40/* The breadcrumb and the clone box share the first row while the column can
41 hold both; the clone box drops to a row of its own only when it cannot join
42 without squeezing the path into a sliver. Everything grows, so whichever row
43 it lands on is filled to the edge instead of leaving a gap. */
4644.repo-code-header-clone { flex: 1 1 240px; min-width: 168px; }
4745.repo-code-header-clone > .repo-clone { width: 100%; min-width: 0; max-width: 100%; }
4846.repo-directory-header { padding: 9px 12px; border-bottom: 1px solid var(--shell-hairline); background: color-mix(in srgb, var(--shell-field) 45%, var(--shell-surface)); }
@@ -57,6 +55,19 @@
5755.repo-file-table button { border-radius: 6px; color: var(--shell-ink); }
5856.repo-file-table button:hover { color: var(--shell-ink); text-decoration: underline; text-underline-offset: 3px; }
5957.repo-readme, .repo-commit-summary, .repo-diff-card, .repo-settings-content, .repo-about-card { box-shadow: 0 1px 2px rgb(24 28 31 / 2%); }
58/* The PDF reader gets its own scrollport and a page-sized box: the server
59 rasterises each page, so the surface has to stay dark behind the white
60 paper instead of inheriting the shell background. */
61.repo-pdf-toolbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px; border: 1px solid var(--shell-hairline); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--shell-surface); }
62.repo-pdf-step { display: flex; height: 30px; min-width: 30px; align-items: center; justify-content: center; border-radius: 7px; color: var(--shell-ink-muted); transition: color 160ms, background 160ms; }
63.repo-pdf-step:hover:not(:disabled) { color: var(--shell-ink); background: var(--shell-field); }
64.repo-pdf-step:disabled { opacity: 40%; }
65.repo-pdf-count { padding: 0 4px; font-size: 12.5px; font-variant-numeric: tabular-nums; color: var(--shell-ink-muted); }
66.repo-pdf-zoom { display: flex; margin-left: auto; align-items: center; gap: 2px; }
67.repo-pdf-scroll { display: flex; height: min(78vh, 900px); min-height: 420px; flex-direction: column; gap: 12px; overflow: auto; padding: 12px; border: 1px solid var(--shell-hairline); border-radius: 0 0 12px 12px; background: color-mix(in srgb, var(--shell-field) 55%, var(--shell-surface)); }
68.repo-pdf-page { display: flex; justify-content: center; }
69.repo-pdf-page img { max-width: 100%; height: auto; border-radius: 3px; background: #fff; box-shadow: 0 1px 3px rgb(24 28 31 / 18%); }
70.repo-pdf-note { padding: 6px 0; text-align: center; font-size: 12.5px; color: var(--shell-ink-muted); }
6071.repo-refs > button { min-height: 36px; border: 1px solid var(--shell-hairline); border-radius: 9px; background: var(--shell-field); font-size: 13px; transition: color 160ms, border-color 160ms, background 160ms; }
6172.repo-refs > button:hover, .repo-refs > button[aria-expanded='true'] { border-color: var(--shell-tile); color: var(--shell-ink-muted); background: var(--shell-field); }
6273.repo-ref-popover { border-radius: 12px; box-shadow: 0 16px 44px rgb(24 28 31 / 16%); }
Modified web/src/shared/i18n/messages/en/repo.ts web/src/shared/i18n/messages/en/repo.ts +17 −14 View web/src/shared/i18n/messages/en/repo.ts
@@ -49,8 +49,6 @@
4949 'repo.commit.whitespace': 'Ignore whitespace',
5050 'repo.commit.filesChanged': '{count} files changed',
5151 'repo.commit.insights': 'Commit insights',
52 'repo.search.placeholder': 'Search this repository',
53 'repo.search.empty': 'No matching files',
5452 'repo.release.create': 'New release',
5553 'repo.release.title': 'Release title',
5654 'repo.release.tag': 'Tag',
@@ -265,6 +263,23 @@
265263 'repo.file.missingOid': 'Missing file identifier; cannot preview in private-repository mode.',
266264 'repo.file.binaryDetail': 'Binary file · {size} · Preview not supported',
267265 'repo.file.truncated': 'The file is large; the server truncated the response.',
266 'repo.file.markdownView': 'Markdown display mode',
267 'repo.file.preview': 'Preview',
268 'repo.file.source': 'Source',
269 'repo.file.unavailable': 'This file cannot be previewed.',
270 'repo.file.truncatedDetail': 'Too large to preview ({size}); the server truncated the response.',
271 'repo.file.pdfLoading': 'Rasterising the first page…',
272 'repo.file.pdfStreaming': 'Rendered {done}/{total} pages; the rest are still arriving…',
273 'repo.file.pdfTruncated': 'This document exceeds 400 pages; only the first 400 are shown.',
274 'repo.file.pdfPageAlt': 'Page {page}',
275 'repo.file.pdfPageFailed': 'Page {page} failed to render.',
276 'repo.file.pdfCounter': '{page} / {total}',
277 'repo.file.pdfPrev': 'Previous page',
278 'repo.file.pdfNext': 'Next page',
279 'repo.file.pdfZoomIn': 'Zoom in',
280 'repo.file.pdfZoomOut': 'Zoom out',
281 'repo.file.pdfFailed': 'This PDF could not be loaded.',
282 'repo.file.pdfUnreadable': 'This PDF could not be parsed; it may be encrypted or corrupt.',
268283 'repo.empty.startTitle': 'Start with the first commit',
269284 'repo.empty.startNote': 'The repository is ready. Add your project files and let the idea take shape here.',
270285 'repo.empty.addFiles': '# add files',
@@ -275,18 +290,6 @@
275290 'repo.blame.what': 'Blame',
276291 'repo.blame.hint': 'Click the attribution on the left to open the commit.',
277292 'repo.revision': 'Revision {rev}',
278 'repo.search.kind.code': 'Code',
279 'repo.search.kind.symbol': 'Symbol',
280 'repo.search.kind.issue': 'Issue',
281 'repo.search.kind.pull': 'PR',
282 'repo.search.failed': 'Search failed',
283 'repo.search.codePlaceholder': 'Search this repository for code or symbols…',
284 'repo.search.codeLabel': 'Search code in the current repository',
285 'repo.search.clear': 'Clear the repository search',
286 'repo.search.defaultBranchNote': 'Results come from the default branch {branch}',
287 'repo.search.searching': 'Searching…',
288 'repo.search.noHits': 'No matching code or symbols',
289 'repo.search.keyboardHint': '↑↓ select · Enter open · Esc close',
290293 'repo.release.loading': 'Loading releases…',
291294 'repo.release.what': 'releases',
292295 'repo.release.backToList': 'Back to list',
Modified web/src/shared/i18n/messages/zh-CN/repo.ts web/src/shared/i18n/messages/zh-CN/repo.ts +17 −14 View web/src/shared/i18n/messages/zh-CN/repo.ts
@@ -49,8 +49,6 @@
4949 'repo.commit.whitespace': '忽略空白',
5050 'repo.commit.filesChanged': '{count} 个文件变更',
5151 'repo.commit.insights': '提交洞察',
52 'repo.search.placeholder': '在仓库中搜索',
53 'repo.search.empty': '没有匹配的文件',
5452 'repo.release.create': '新建发布',
5553 'repo.release.title': '发布标题',
5654 'repo.release.tag': '标签',
@@ -265,6 +263,23 @@
265263 'repo.file.missingOid': '缺少文件标识,无法在私有仓库模式下预览。',
266264 'repo.file.binaryDetail': '二进制文件 · {size} · 暂不支持预览',
267265 'repo.file.truncated': '文件较大,服务端已截断返回。',
266 'repo.file.markdownView': 'Markdown 显示方式',
267 'repo.file.preview': '预览',
268 'repo.file.source': '源码',
269 'repo.file.unavailable': '该文件暂不支持预览。',
270 'repo.file.truncatedDetail': '文件过大({size}),服务端已截断,无法预览。',
271 'repo.file.pdfLoading': '正在栅格化首页…',
272 'repo.file.pdfStreaming': '已渲染 {done}/{total} 页,其余页面随后到达…',
273 'repo.file.pdfTruncated': '文档页数超过 400 页,仅呈现前 400 页。',
274 'repo.file.pdfPageAlt': '第 {page} 页',
275 'repo.file.pdfPageFailed': '第 {page} 页渲染失败。',
276 'repo.file.pdfCounter': '{page} / {total}',
277 'repo.file.pdfPrev': '上一页',
278 'repo.file.pdfNext': '下一页',
279 'repo.file.pdfZoomIn': '放大',
280 'repo.file.pdfZoomOut': '缩小',
281 'repo.file.pdfFailed': '无法读取该 PDF 文件。',
282 'repo.file.pdfUnreadable': '该 PDF 无法解析,可能已加密或损坏。',
268283 'repo.empty.startTitle': '从第一个提交开始',
269284 'repo.empty.startNote': '仓库已经准备好了。添加项目文件,让想法在这里慢慢成形。',
270285 'repo.empty.addFiles': '# 添加文件',
@@ -275,18 +290,6 @@
275290 'repo.blame.what': '逐行归属',
276291 'repo.blame.hint': '点击左侧归属跳转到提交详情。',
277292 'repo.revision': '修订 {rev}',
278 'repo.search.kind.code': '代码',
279 'repo.search.kind.symbol': '符号',
280 'repo.search.kind.issue': 'Issue',
281 'repo.search.kind.pull': 'PR',
282 'repo.search.failed': '搜索失败',
283 'repo.search.codePlaceholder': '在仓库中搜索代码或符号…',
284 'repo.search.codeLabel': '搜索当前仓库代码',
285 'repo.search.clear': '清除仓库搜索',
286 'repo.search.defaultBranchNote': '结果来自默认分支 {branch}',
287 'repo.search.searching': '正在搜索…',
288 'repo.search.noHits': '没有匹配的代码或符号',
289 'repo.search.keyboardHint': '↑↓ 选择 · Enter 打开 · Esc 关闭',
290293 'repo.release.loading': '加载发布记录…',
291294 'repo.release.what': '发布记录',
292295 'repo.release.backToList': '返回列表',
Modified Cargo.lock Cargo.lock +452 −3 View Cargo.lock
@@ -216,6 +216,21 @@
216216checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
217217
218218[[package]]
219name = "alloc-no-stdlib"
220version = "2.0.4"
221source = "registry+https://github.com/rust-lang/crates.io-index"
222checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
223
224[[package]]
225name = "alloc-stdlib"
226version = "0.2.4"
227source = "registry+https://github.com/rust-lang/crates.io-index"
228checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
229dependencies = [
230 "alloc-no-stdlib",
231]
232
233[[package]]
219234name = "allocator-api2"
220235version = "0.2.21"
221236source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1055,6 +1070,27 @@
10551070]
10561071
10571072[[package]]
1073name = "brotli"
1074version = "8.0.4"
1075source = "registry+https://github.com/rust-lang/crates.io-index"
1076checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
1077dependencies = [
1078 "alloc-no-stdlib",
1079 "alloc-stdlib",
1080 "brotli-decompressor",
1081]
1082
1083[[package]]
1084name = "brotli-decompressor"
1085version = "5.0.3"
1086source = "registry+https://github.com/rust-lang/crates.io-index"
1087checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
1088dependencies = [
1089 "alloc-no-stdlib",
1090 "alloc-stdlib",
1091]
1092
1093[[package]]
10581094name = "bstr"
10591095version = "1.13.1"
10601096source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1098,6 +1134,20 @@
10981134version = "1.25.2"
10991135source = "registry+https://github.com/rust-lang/crates.io-index"
11001136checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
1137dependencies = [
1138 "bytemuck_derive",
1139]
1140
1141[[package]]
1142name = "bytemuck_derive"
1143version = "1.12.1"
1144source = "registry+https://github.com/rust-lang/crates.io-index"
1145checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
1146dependencies = [
1147 "proc-macro2",
1148 "quote",
1149 "syn 3.0.3",
1150]
11011151
11021152[[package]]
11031153name = "byteorder"
@@ -1106,6 +1156,12 @@
11061156checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
11071157
11081158[[package]]
1159name = "byteorder-lite"
1160version = "0.1.0"
1161source = "registry+https://github.com/rust-lang/crates.io-index"
1162checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
1163
1164[[package]]
11091165name = "bytes"
11101166version = "1.12.1"
11111167source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1345,6 +1401,15 @@
13451401checksum = "d7ee2cfacbd29706479902b06d75ad8f1362900836aa32799eabc7e004bfd854"
13461402
13471403[[package]]
1404name = "color"
1405version = "0.3.3"
1406source = "registry+https://github.com/rust-lang/crates.io-index"
1407checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
1408dependencies = [
1409 "bytemuck",
1410]
1411
1412[[package]]
13481413name = "combine"
13491414version = "4.6.7"
13501415source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2219,6 +2284,15 @@
22192284]
22202285
22212286[[package]]
2287name = "euclid"
2288version = "0.22.14"
2289source = "registry+https://github.com/rust-lang/crates.io-index"
2290checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
2291dependencies = [
2292 "num-traits",
2293]
2294
2295[[package]]
22222296name = "event-listener"
22232297version = "5.4.2"
22242298source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2317,6 +2391,21 @@
23172391checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
23182392
23192393[[package]]
2394name = "fdeflate"
2395version = "0.3.7"
2396source = "registry+https://github.com/rust-lang/crates.io-index"
2397checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
2398dependencies = [
2399 "simd-adler32",
2400]
2401
2402[[package]]
2403name = "fearless_simd"
2404version = "0.4.1"
2405source = "registry+https://github.com/rust-lang/crates.io-index"
2406checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
2407
2408[[package]]
23202409name = "ff"
23212410version = "0.13.1"
23222411source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2424,6 +2513,15 @@
24242513checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
24252514
24262515[[package]]
2516name = "font-types"
2517version = "0.11.3"
2518source = "registry+https://github.com/rust-lang/crates.io-index"
2519checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
2520dependencies = [
2521 "bytemuck",
2522]
2523
2524[[package]]
24272525name = "foreign-types"
24282526version = "0.3.2"
24292527source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2808,6 +2906,7 @@
28082906 "gitbaby",
28092907 "gix",
28102908 "globset",
2909 "hayro",
28112910 "http-body-util",
28122911 "maud",
28132912 "models",
@@ -3279,7 +3378,7 @@
32793378checksum = "1c91d8cffac8849493a82233811bd02b2b183b8cf39bf704de0fa0841b737595"
32803379dependencies = [
32813380 "bstr",
3282 "hashbrown 0.17.1",
3381 "hashbrown 0.16.1",
32833382]
32843383
32853384[[package]]
@@ -3796,6 +3895,23 @@
37963895]
37973896
37983897[[package]]
3898name = "glifo"
3899version = "0.1.1"
3900source = "registry+https://github.com/rust-lang/crates.io-index"
3901checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
3902dependencies = [
3903 "bytemuck",
3904 "foldhash",
3905 "hashbrown 0.17.1",
3906 "log 0.4.33",
3907 "peniko",
3908 "png 0.18.1",
3909 "skrifa",
3910 "smallvec",
3911 "vello_common 0.0.9",
3912]
3913
3914[[package]]
37993915name = "globset"
38003916version = "0.4.20"
38013917source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3831,6 +3947,15 @@
38313947]
38323948
38333949[[package]]
3950name = "guillotiere"
3951version = "0.7.0"
3952source = "registry+https://github.com/rust-lang/crates.io-index"
3953checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
3954dependencies = [
3955 "euclid",
3956]
3957
3958[[package]]
38343959name = "h2"
38353960version = "0.3.27"
38363961source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3933,6 +4058,96 @@
39334058]
39344059
39354060[[package]]
4061name = "hayro"
4062version = "0.7.1"
4063source = "registry+https://github.com/rust-lang/crates.io-index"
4064checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d"
4065dependencies = [
4066 "bytemuck",
4067 "hayro-interpret",
4068 "image",
4069 "kurbo",
4070 "pic-scale",
4071 "vello_cpu",
4072]
4073
4074[[package]]
4075name = "hayro-ccitt"
4076version = "0.3.0"
4077source = "registry+https://github.com/rust-lang/crates.io-index"
4078checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
4079
4080[[package]]
4081name = "hayro-cmap"
4082version = "0.1.0"
4083source = "registry+https://github.com/rust-lang/crates.io-index"
4084checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
4085dependencies = [
4086 "brotli",
4087 "hayro-postscript",
4088]
4089
4090[[package]]
4091name = "hayro-interpret"
4092version = "0.7.0"
4093source = "registry+https://github.com/rust-lang/crates.io-index"
4094checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d"
4095dependencies = [
4096 "bitflags 2.13.1",
4097 "hayro-cmap",
4098 "hayro-syntax",
4099 "kurbo",
4100 "moxcms",
4101 "phf",
4102 "rustc-hash",
4103 "siphasher",
4104 "skrifa",
4105 "smallvec",
4106 "yoke",
4107]
4108
4109[[package]]
4110name = "hayro-jbig2"
4111version = "0.3.0"
4112source = "registry+https://github.com/rust-lang/crates.io-index"
4113checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
4114dependencies = [
4115 "fearless_simd",
4116 "hayro-ccitt",
4117]
4118
4119[[package]]
4120name = "hayro-jpeg2000"
4121version = "0.3.5"
4122source = "registry+https://github.com/rust-lang/crates.io-index"
4123checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
4124dependencies = [
4125 "fearless_simd",
4126]
4127
4128[[package]]
4129name = "hayro-postscript"
4130version = "0.1.0"
4131source = "registry+https://github.com/rust-lang/crates.io-index"
4132checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
4133
4134[[package]]
4135name = "hayro-syntax"
4136version = "0.7.2"
4137source = "registry+https://github.com/rust-lang/crates.io-index"
4138checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
4139dependencies = [
4140 "flate2",
4141 "hayro-ccitt",
4142 "hayro-jbig2",
4143 "hayro-jpeg2000",
4144 "memchr",
4145 "rustc-hash",
4146 "smallvec",
4147 "zune-jpeg",
4148]
4149
4150[[package]]
39364151name = "heapless"
39374152version = "0.8.0"
39384153source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4733,6 +4948,18 @@
47334948]
47344949
47354950[[package]]
4951name = "image"
4952version = "0.25.6"
4953source = "registry+https://github.com/rust-lang/crates.io-index"
4954checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a"
4955dependencies = [
4956 "bytemuck",
4957 "byteorder-lite",
4958 "num-traits",
4959 "png 0.17.16",
4960]
4961
4962[[package]]
47364963name = "import"
47374964version = "1.0.0"
47384965dependencies = [
@@ -5083,6 +5310,18 @@
50835310]
50845311
50855312[[package]]
5313name = "kurbo"
5314version = "0.13.1"
5315source = "registry+https://github.com/rust-lang/crates.io-index"
5316checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
5317dependencies = [
5318 "arrayvec",
5319 "euclid",
5320 "polycool",
5321 "smallvec",
5322]
5323
5324[[package]]
50865325name = "language-tags"
50875326version = "0.2.2"
50885327source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5213,6 +5452,12 @@
52135452]
52145453
52155454[[package]]
5455name = "linebender_resource_handle"
5456version = "0.1.1"
5457source = "registry+https://github.com/rust-lang/crates.io-index"
5458checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
5459
5460[[package]]
52165461name = "linux-raw-sys"
52175462version = "0.12.1"
52185463source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5644,6 +5889,16 @@
56445889]
56455890
56465891[[package]]
5892name = "moxcms"
5893version = "0.8.1"
5894source = "registry+https://github.com/rust-lang/crates.io-index"
5895checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
5896dependencies = [
5897 "num-traits",
5898 "pxfm",
5899]
5900
5901[[package]]
56475902name = "multer"
56485903version = "3.1.0"
56495904source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6497,6 +6752,19 @@
64976752]
64986753
64996754[[package]]
6755name = "peniko"
6756version = "0.6.1"
6757source = "registry+https://github.com/rust-lang/crates.io-index"
6758checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
6759dependencies = [
6760 "bytemuck",
6761 "color",
6762 "kurbo",
6763 "linebender_resource_handle",
6764 "smallvec",
6765]
6766
6767[[package]]
65006768name = "percent-encoding"
65016769version = "1.0.1"
65026770source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6581,6 +6849,58 @@
65816849]
65826850
65836851[[package]]
6852name = "phf"
6853version = "0.13.1"
6854source = "registry+https://github.com/rust-lang/crates.io-index"
6855checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
6856dependencies = [
6857 "phf_macros",
6858 "phf_shared",
6859]
6860
6861[[package]]
6862name = "phf_generator"
6863version = "0.13.1"
6864source = "registry+https://github.com/rust-lang/crates.io-index"
6865checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
6866dependencies = [
6867 "fastrand",
6868 "phf_shared",
6869]
6870
6871[[package]]
6872name = "phf_macros"
6873version = "0.13.1"
6874source = "registry+https://github.com/rust-lang/crates.io-index"
6875checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
6876dependencies = [
6877 "phf_generator",
6878 "phf_shared",
6879 "proc-macro2",
6880 "quote",
6881 "syn 2.0.119",
6882]
6883
6884[[package]]
6885name = "phf_shared"
6886version = "0.13.1"
6887source = "registry+https://github.com/rust-lang/crates.io-index"
6888checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
6889dependencies = [
6890 "siphasher",
6891]
6892
6893[[package]]
6894name = "pic-scale"
6895version = "0.7.12"
6896source = "registry+https://github.com/rust-lang/crates.io-index"
6897checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288"
6898dependencies = [
6899 "num-traits",
6900 "pxfm",
6901]
6902
6903[[package]]
65846904name = "pin-project"
65856905version = "1.1.13"
65866906source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6683,6 +7003,32 @@
66837003]
66847004
66857005[[package]]
7006name = "png"
7007version = "0.17.16"
7008source = "registry+https://github.com/rust-lang/crates.io-index"
7009checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
7010dependencies = [
7011 "bitflags 1.3.2",
7012 "crc32fast",
7013 "fdeflate",
7014 "flate2",
7015 "miniz_oxide",
7016]
7017
7018[[package]]
7019name = "png"
7020version = "0.18.1"
7021source = "registry+https://github.com/rust-lang/crates.io-index"
7022checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
7023dependencies = [
7024 "bitflags 2.13.1",
7025 "crc32fast",
7026 "fdeflate",
7027 "flate2",
7028 "miniz_oxide",
7029]
7030
7031[[package]]
66867032name = "polling"
66877033version = "3.11.0"
66887034source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6708,6 +7054,15 @@
67087054]
67097055
67107056[[package]]
7057name = "polycool"
7058version = "0.4.0"
7059source = "registry+https://github.com/rust-lang/crates.io-index"
7060checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
7061dependencies = [
7062 "arrayvec",
7063]
7064
7065[[package]]
67117066name = "polyval"
67127067version = "0.6.2"
67137068source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7018,6 +7373,12 @@
70187373]
70197374
70207375[[package]]
7376name = "pxfm"
7377version = "0.1.30"
7378source = "registry+https://github.com/rust-lang/crates.io-index"
7379checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
7380
7381[[package]]
70217382name = "quick-xml"
70227383version = "0.28.2"
70237384source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7243,6 +7604,16 @@
72437604]
72447605
72457606[[package]]
7607name = "read-fonts"
7608version = "0.39.2"
7609source = "registry+https://github.com/rust-lang/crates.io-index"
7610checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
7611dependencies = [
7612 "bytemuck",
7613 "font-types",
7614]
7615
7616[[package]]
72467617name = "redis"
72477618version = "1.6.0"
72487619source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8406,6 +8777,22 @@
84068777]
84078778
84088779[[package]]
8780name = "siphasher"
8781version = "1.0.3"
8782source = "registry+https://github.com/rust-lang/crates.io-index"
8783checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
8784
8785[[package]]
8786name = "skrifa"
8787version = "0.42.1"
8788source = "registry+https://github.com/rust-lang/crates.io-index"
8789checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
8790dependencies = [
8791 "bytemuck",
8792 "read-fonts",
8793]
8794
8795[[package]]
84098796name = "slab"
84108797version = "0.4.12"
84118798source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -9006,7 +9393,7 @@
90069393checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
90079394dependencies = [
90089395 "fastrand",
9009 "getrandom 0.4.3",
9396 "getrandom 0.3.4",
90109397 "once_cell",
90119398 "rustix",
90129399 "windows-sys 0.61.2",
@@ -9560,7 +9947,7 @@
95609947source = "registry+https://github.com/rust-lang/crates.io-index"
95619948checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9"
95629949dependencies = [
9563 "rand 0.10.2",
9950 "rand 0.9.5",
95649951]
95659952
95669953[[package]]
@@ -9822,6 +10209,53 @@
982210209checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
982310210
982410211[[package]]
10212name = "vello_common"
10213version = "0.0.8"
10214source = "registry+https://github.com/rust-lang/crates.io-index"
10215checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee"
10216dependencies = [
10217 "bytemuck",
10218 "fearless_simd",
10219 "guillotiere",
10220 "hashbrown 0.17.1",
10221 "log 0.4.33",
10222 "peniko",
10223 "png 0.18.1",
10224 "smallvec",
10225 "thiserror 2.0.20",
10226]
10227
10228[[package]]
10229name = "vello_common"
10230version = "0.0.9"
10231source = "registry+https://github.com/rust-lang/crates.io-index"
10232checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750"
10233dependencies = [
10234 "bytemuck",
10235 "fearless_simd",
10236 "guillotiere",
10237 "hashbrown 0.17.1",
10238 "log 0.4.33",
10239 "peniko",
10240 "png 0.18.1",
10241 "smallvec",
10242 "thiserror 2.0.20",
10243]
10244
10245[[package]]
10246name = "vello_cpu"
10247version = "0.0.8"
10248source = "registry+https://github.com/rust-lang/crates.io-index"
10249checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0"
10250dependencies = [
10251 "bytemuck",
10252 "glifo",
10253 "hashbrown 0.17.1",
10254 "png 0.18.1",
10255 "vello_common 0.0.8",
10256]
10257
10258[[package]]
982510259name = "version_check"
982610260version = "0.1.5"
982710261source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10664,3 +11098,18 @@
1066411098 "cc",
1066511099 "pkg-config",
1066611100]
11101
11102[[package]]
11103name = "zune-core"
11104version = "0.5.3"
11105source = "registry+https://github.com/rust-lang/crates.io-index"
11106checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
11107
11108[[package]]
11109name = "zune-jpeg"
11110version = "0.5.15"
11111source = "registry+https://github.com/rust-lang/crates.io-index"
11112checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
11113dependencies = [
11114 "zune-core",
11115]
Modified Cargo.toml Cargo.toml +1 −0 View Cargo.toml
@@ -88,6 +88,7 @@
8888thiserror = "2.0.20"
8989gix = "0.86.0"
9090gitbaby = { git="https://gitea.gitdata.me/GitDataAi/GitBaby.git", rev = "69bd99a" }
91hayro = { version = "0.7.1", features = ["embed-fonts", "embed-cmaps"] }
9192wasm-bindgen = "0.2"
9293chrono = "0.4.45"
9394maud = { version = "0.27", features = ["axum"] }