Fix branch names with slashes in tree/blob/commits routes
Branch names like "feat/initial-implementation" were being split on "/" by both the API router and Astro page routes, causing "Ref not found" errors. Changed tree/blob routes to capture the full remaining path, then resolve the ref by matching against known branch names from longest to shortest. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Maher Kassim committed on Apr 3, 2026, 04:31 PM
9327dbcc01 Parent:
49d02a2
Showing 6 changed files with
257 additions and
213 deletions
A packages/ui/src/pages/[owner]/[repo]/blob/[...refAndPath].astro
| @@ -1,0 +1,112 @@ | ||
| 1 | +--- | |
| 2 | +import Repo from '../../../../layouts/Repo.astro'; | |
| 3 | +import { apiGet } from '../../../../lib/api'; | |
| 4 | + | |
| 5 | +const { owner, repo, refAndPath } = Astro.params; | |
| 6 | +const cookie = Astro.request.headers.get('cookie') || ''; | |
| 7 | + | |
| 8 | +let blob: any = null; | |
| 9 | +let ref = ''; | |
| 10 | +let path = ''; | |
| 11 | +let error = ''; | |
| 12 | + | |
| 13 | +try { | |
| 14 | + blob = await apiGet(`/api/repos/${owner}/${repo}/blob/${refAndPath}`, cookie); | |
| 15 | + ref = blob.ref || ''; | |
| 16 | + path = blob.path || ''; | |
| 17 | +} catch (e: any) { | |
| 18 | + error = e.message; | |
| 19 | +} | |
| 20 | + | |
| 21 | +const pathSegments = path.split('/').filter(Boolean); | |
| 22 | +const fileName = pathSegments[pathSegments.length - 1] || ''; | |
| 23 | + | |
| 24 | +const breadcrumbs = pathSegments.map((seg: string, i: number) => ({ | |
| 25 | + name: seg, | |
| 26 | + href: i < pathSegments.length - 1 | |
| 27 | + ? `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, i + 1).join('/')}` | |
| 28 | + : `/${owner}/${repo}/blob/${ref}/${pathSegments.slice(0, i + 1).join('/')}`, | |
| 29 | + isLast: i === pathSegments.length - 1, | |
| 30 | +})); | |
| 31 | + | |
| 32 | +const lines = blob?.content?.split('\n') || []; | |
| 33 | +const isBinary = blob?.encoding === 'base64'; | |
| 34 | + | |
| 35 | +function formatSize(bytes: number): string { | |
| 36 | + if (bytes < 1024) return `${bytes} B`; | |
| 37 | + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; | |
| 38 | + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; | |
| 39 | +} | |
| 40 | +--- | |
| 41 | + | |
| 42 | +<Repo owner={owner!} repo={repo!} activeTab="code" ref={ref}> | |
| 43 | + {error && <div class="flash-error">{error}</div>} | |
| 44 | + | |
| 45 | + {/* Breadcrumb */} | |
| 46 | + <nav style="margin-bottom: 16px; font-size: 0.875rem;"> | |
| 47 | + <a href={`/${owner}/${repo}`}>{repo}</a> | |
| 48 | + {breadcrumbs.map((bc: any) => ( | |
| 49 | + <> | |
| 50 | + <span style="color: var(--text-muted);"> / </span> | |
| 51 | + {bc.isLast ? ( | |
| 52 | + <strong>{bc.name}</strong> | |
| 53 | + ) : ( | |
| 54 | + <a href={bc.href}>{bc.name}</a> | |
| 55 | + )} | |
| 56 | + </> | |
| 57 | + ))} | |
| 58 | + </nav> | |
| 59 | + | |
| 60 | + {blob && ( | |
| 61 | + <div class="card" style="padding: 0; overflow: hidden;"> | |
| 62 | + {/* File header */} | |
| 63 | + <div style="padding: 8px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center;"> | |
| 64 | + <span style="font-size: 0.8125rem; color: var(--text-muted);"> | |
| 65 | + {lines.length} lines · {formatSize(blob.size)} | |
| 66 | + </span> | |
| 67 | + </div> | |
| 68 | + | |
| 69 | + {isBinary ? ( | |
| 70 | + <div style="padding: 32px; text-align: center; color: var(--text-muted);"> | |
| 71 | + Binary file ({formatSize(blob.size)}) | |
| 72 | + </div> | |
| 73 | + ) : ( | |
| 74 | + <div style="overflow-x: auto;"> | |
| 75 | + <table style="border-collapse: collapse; width: 100%;"> | |
| 76 | + <tbody> | |
| 77 | + {lines.map((line: string, i: number) => ( | |
| 78 | + <tr> | |
| 79 | + <td style=" | |
| 80 | + padding: 0 12px; | |
| 81 | + text-align: right; | |
| 82 | + color: var(--text-muted); | |
| 83 | + user-select: none; | |
| 84 | + white-space: nowrap; | |
| 85 | + font-family: var(--font-mono); | |
| 86 | + font-size: 0.8125rem; | |
| 87 | + line-height: 1.5; | |
| 88 | + vertical-align: top; | |
| 89 | + border-right: 1px solid var(--border); | |
| 90 | + min-width: 50px; | |
| 91 | + "> | |
| 92 | + {i + 1} | |
| 93 | + </td> | |
| 94 | + <td style=" | |
| 95 | + padding: 0 12px; | |
| 96 | + white-space: pre; | |
| 97 | + font-family: var(--font-mono); | |
| 98 | + font-size: 0.8125rem; | |
| 99 | + line-height: 1.5; | |
| 100 | + "> | |
| 101 | + {line} | |
| 102 | + </td> | |
| 103 | + </tr> | |
| 104 | + ))} | |
| 105 | + </tbody> | |
| 106 | + </table> | |
| 107 | + </div> | |
| 108 | + )} | |
| 109 | + </div> | |
| 110 | + )} | |
| 111 | +</Repo> | |
| 112 | + | |
D packages/ui/src/pages/[owner]/[repo]/blob/[ref]/[...path].astro
| @@ -1,119 +1,0 @@ | ||
| 1 | ---- | |
| 2 | -import Repo from '../../../../../layouts/Repo.astro'; | |
| 3 | -import { apiGet } from '../../../../../lib/api'; | |
| 4 | - | |
| 5 | -const { owner, repo, ref, path } = Astro.params; | |
| 6 | -const cookie = Astro.request.headers.get('cookie') || ''; | |
| 7 | - | |
| 8 | -let blob: any = null; | |
| 9 | -let error = ''; | |
| 10 | - | |
| 11 | -try { | |
| 12 | - blob = await apiGet(`/api/repos/${owner}/${repo}/blob/${ref}/${path}`, cookie); | |
| 13 | -} catch (e: any) { | |
| 14 | - error = e.message; | |
| 15 | -} | |
| 16 | - | |
| 17 | -// Build breadcrumb segments | |
| 18 | -const pathSegments = (path || '').split('/').filter(Boolean); | |
| 19 | -const fileName = pathSegments[pathSegments.length - 1] || ''; | |
| 20 | -const dirPath = pathSegments.slice(0, -1).join('/'); | |
| 21 | - | |
| 22 | -const breadcrumbs = pathSegments.map((seg, i) => ({ | |
| 23 | - name: seg, | |
| 24 | - href: i < pathSegments.length - 1 | |
| 25 | - ? `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, i + 1).join('/')}` | |
| 26 | - : `/${owner}/${repo}/blob/${ref}/${pathSegments.slice(0, i + 1).join('/')}`, | |
| 27 | - isLast: i === pathSegments.length - 1, | |
| 28 | -})); | |
| 29 | - | |
| 30 | -// Determine language for basic syntax hint | |
| 31 | -const ext = fileName.split('.').pop()?.toLowerCase() || ''; | |
| 32 | -const lines = blob?.content?.split('\n') || []; | |
| 33 | -const isBinary = blob?.encoding === 'base64'; | |
| 34 | - | |
| 35 | -function formatSize(bytes: number): string { | |
| 36 | - if (bytes < 1024) return `${bytes} B`; | |
| 37 | - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; | |
| 38 | - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; | |
| 39 | -} | |
| 40 | ---- | |
| 41 | - | |
| 42 | -<Repo owner={owner!} repo={repo!} activeTab="code" ref={ref}> | |
| 43 | - {error && <div class="flash-error">{error}</div>} | |
| 44 | - | |
| 45 | - {/* Breadcrumb */} | |
| 46 | - <nav style="margin-bottom: 16px; font-size: 0.875rem;"> | |
| 47 | - <a href={`/${owner}/${repo}`}>{repo}</a> | |
| 48 | - {breadcrumbs.map((bc) => ( | |
| 49 | - <> | |
| 50 | - <span style="color: var(--text-muted);"> / </span> | |
| 51 | - {bc.isLast ? ( | |
| 52 | - <strong>{bc.name}</strong> | |
| 53 | - ) : ( | |
| 54 | - <a href={bc.href}>{bc.name}</a> | |
| 55 | - )} | |
| 56 | - </> | |
| 57 | - ))} | |
| 58 | - </nav> | |
| 59 | - | |
| 60 | - {blob && ( | |
| 61 | - <div class="card" style="padding: 0; overflow: hidden;"> | |
| 62 | - {/* File header */} | |
| 63 | - <div style="padding: 8px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center;"> | |
| 64 | - <span style="font-size: 0.8125rem; color: var(--text-muted);"> | |
| 65 | - {lines.length} lines · {formatSize(blob.size)} | |
| 66 | - </span> | |
| 67 | - <a | |
| 68 | - href={`/api/repos/${owner}/${repo}/blob/${ref}/${path}`} | |
| 69 | - class="btn" | |
| 70 | - style="font-size: 0.75rem; padding: 2px 10px;" | |
| 71 | - > | |
| 72 | - Raw | |
| 73 | - </a> | |
| 74 | - </div> | |
| 75 | - | |
| 76 | - {isBinary ? ( | |
| 77 | - <div style="padding: 32px; text-align: center; color: var(--text-muted);"> | |
| 78 | - Binary file ({formatSize(blob.size)}) | |
| 79 | - </div> | |
| 80 | - ) : ( | |
| 81 | - <div style="overflow-x: auto;"> | |
| 82 | - <table style="border-collapse: collapse; width: 100%;"> | |
| 83 | - <tbody> | |
| 84 | - {lines.map((line: string, i: number) => ( | |
| 85 | - <tr> | |
| 86 | - <td style=" | |
| 87 | - padding: 0 12px; | |
| 88 | - text-align: right; | |
| 89 | - color: var(--text-muted); | |
| 90 | - user-select: none; | |
| 91 | - white-space: nowrap; | |
| 92 | - font-family: var(--font-mono); | |
| 93 | - font-size: 0.8125rem; | |
| 94 | - line-height: 1.5; | |
| 95 | - vertical-align: top; | |
| 96 | - border-right: 1px solid var(--border); | |
| 97 | - min-width: 50px; | |
| 98 | - "> | |
| 99 | - {i + 1} | |
| 100 | - </td> | |
| 101 | - <td style=" | |
| 102 | - padding: 0 12px; | |
| 103 | - white-space: pre; | |
| 104 | - font-family: var(--font-mono); | |
| 105 | - font-size: 0.8125rem; | |
| 106 | - line-height: 1.5; | |
| 107 | - "> | |
| 108 | - {line} | |
| 109 | - </td> | |
| 110 | - </tr> | |
| 111 | - ))} | |
| 112 | - </tbody> | |
| 113 | - </table> | |
| 114 | - </div> | |
| 115 | - )} | |
| 116 | - </div> | |
| 117 | - )} | |
| 118 | -</Repo> | |
| 119 | - | |
A packages/ui/src/pages/[owner]/[repo]/tree/[...refAndPath].astro
| @@ -1,0 +1,80 @@ | ||
| 1 | +--- | |
| 2 | +import Repo from '../../../../layouts/Repo.astro'; | |
| 3 | +import { apiGet } from '../../../../lib/api'; | |
| 4 | + | |
| 5 | +const { owner, repo, refAndPath } = Astro.params; | |
| 6 | +const cookie = Astro.request.headers.get('cookie') || ''; | |
| 7 | + | |
| 8 | +let tree: any = null; | |
| 9 | +let ref = ''; | |
| 10 | +let path = ''; | |
| 11 | +let error = ''; | |
| 12 | + | |
| 13 | +try { | |
| 14 | + tree = await apiGet(`/api/repos/${owner}/${repo}/tree/${refAndPath}`, cookie); | |
| 15 | + ref = tree.ref || ''; | |
| 16 | + path = tree.path || ''; | |
| 17 | +} catch (e: any) { | |
| 18 | + error = e.message; | |
| 19 | +} | |
| 20 | + | |
| 21 | +// Build breadcrumb segments from the resolved path | |
| 22 | +const pathSegments = path.split('/').filter(Boolean); | |
| 23 | +const breadcrumbs = pathSegments.map((seg: string, i: number) => ({ | |
| 24 | + name: seg, | |
| 25 | + href: `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, i + 1).join('/')}`, | |
| 26 | +})); | |
| 27 | +--- | |
| 28 | + | |
| 29 | +<Repo owner={owner!} repo={repo!} activeTab="code" ref={ref}> | |
| 30 | + {error && <div class="flash-error">{error}</div>} | |
| 31 | + | |
| 32 | + {/* Breadcrumb */} | |
| 33 | + <nav style="margin-bottom: 16px; font-size: 0.875rem;"> | |
| 34 | + <a href={`/${owner}/${repo}`}>{repo}</a> | |
| 35 | + {breadcrumbs.map((bc: any) => ( | |
| 36 | + <> | |
| 37 | + <span style="color: var(--text-muted);"> / </span> | |
| 38 | + <a href={bc.href}>{bc.name}</a> | |
| 39 | + </> | |
| 40 | + ))} | |
| 41 | + </nav> | |
| 42 | + | |
| 43 | + {tree && ( | |
| 44 | + <div class="card" style="padding: 0; overflow: hidden;"> | |
| 45 | + <table style="width: 100%; border-collapse: collapse;"> | |
| 46 | + <tbody> | |
| 47 | + {/* ".." entry to go up */} | |
| 48 | + {pathSegments.length > 0 && ( | |
| 49 | + <tr style="border-bottom: 1px solid var(--border);"> | |
| 50 | + <td style="padding: 8px 16px; font-size: 0.875rem;"> | |
| 51 | + <a href={pathSegments.length === 1 | |
| 52 | + ? `/${owner}/${repo}/tree/${ref}` | |
| 53 | + : `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, -1).join('/')}` | |
| 54 | + }>..</a> | |
| 55 | + </td> | |
| 56 | + </tr> | |
| 57 | + )} | |
| 58 | + {tree.entries.map((entry: any) => { | |
| 59 | + const fullPath = path ? `${path}/${entry.name}` : entry.name; | |
| 60 | + return ( | |
| 61 | + <tr style="border-bottom: 1px solid var(--border);"> | |
| 62 | + <td style="padding: 8px 16px; font-size: 0.875rem;"> | |
| 63 | + <span style="color: var(--text-muted); margin-right: 8px;"> | |
| 64 | + {entry.type === 'dir' ? '\u{1F4C1}' : '\u{1F4C4}'} | |
| 65 | + </span> | |
| 66 | + {entry.type === 'dir' ? ( | |
| 67 | + <a href={`/${owner}/${repo}/tree/${ref}/${fullPath}`}>{entry.name}</a> | |
| 68 | + ) : ( | |
| 69 | + <a href={`/${owner}/${repo}/blob/${ref}/${fullPath}`}>{entry.name}</a> | |
| 70 | + )} | |
| 71 | + </td> | |
| 72 | + </tr> | |
| 73 | + ); | |
| 74 | + })} | |
| 75 | + </tbody> | |
| 76 | + </table> | |
| 77 | + </div> | |
| 78 | + )} | |
| 79 | +</Repo> | |
| 80 | + | |
D packages/ui/src/pages/[owner]/[repo]/tree/[ref]/[...path].astro
| @@ -1,77 +1,0 @@ | ||
| 1 | ---- | |
| 2 | -import Repo from '../../../../../layouts/Repo.astro'; | |
| 3 | -import { apiGet } from '../../../../../lib/api'; | |
| 4 | - | |
| 5 | -const { owner, repo, ref, path } = Astro.params; | |
| 6 | -const cookie = Astro.request.headers.get('cookie') || ''; | |
| 7 | - | |
| 8 | -let tree: any = null; | |
| 9 | -let error = ''; | |
| 10 | - | |
| 11 | -try { | |
| 12 | - const pathStr = path || ''; | |
| 13 | - tree = await apiGet(`/api/repos/${owner}/${repo}/tree/${ref}/${pathStr}`, cookie); | |
| 14 | -} catch (e: any) { | |
| 15 | - error = e.message; | |
| 16 | -} | |
| 17 | - | |
| 18 | -// Build breadcrumb segments | |
| 19 | -const pathSegments = (path || '').split('/').filter(Boolean); | |
| 20 | -const breadcrumbs = pathSegments.map((seg, i) => ({ | |
| 21 | - name: seg, | |
| 22 | - href: `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, i + 1).join('/')}`, | |
| 23 | -})); | |
| 24 | ---- | |
| 25 | - | |
| 26 | -<Repo owner={owner!} repo={repo!} activeTab="code" ref={ref}> | |
| 27 | - {error && <div class="flash-error">{error}</div>} | |
| 28 | - | |
| 29 | - {/* Breadcrumb */} | |
| 30 | - <nav style="margin-bottom: 16px; font-size: 0.875rem;"> | |
| 31 | - <a href={`/${owner}/${repo}`}>{repo}</a> | |
| 32 | - {breadcrumbs.map((bc) => ( | |
| 33 | - <> | |
| 34 | - <span style="color: var(--text-muted);"> / </span> | |
| 35 | - <a href={bc.href}>{bc.name}</a> | |
| 36 | - </> | |
| 37 | - ))} | |
| 38 | - </nav> | |
| 39 | - | |
| 40 | - {tree && ( | |
| 41 | - <div class="card" style="padding: 0; overflow: hidden;"> | |
| 42 | - <table style="width: 100%; border-collapse: collapse;"> | |
| 43 | - <tbody> | |
| 44 | - {/* ".." entry to go up */} | |
| 45 | - {pathSegments.length > 0 && ( | |
| 46 | - <tr style="border-bottom: 1px solid var(--border);"> | |
| 47 | - <td style="padding: 8px 16px; font-size: 0.875rem;"> | |
| 48 | - <a href={pathSegments.length === 1 | |
| 49 | - ? `/${owner}/${repo}` | |
| 50 | - : `/${owner}/${repo}/tree/${ref}/${pathSegments.slice(0, -1).join('/')}` | |
| 51 | - }>..</a> | |
| 52 | - </td> | |
| 53 | - </tr> | |
| 54 | - )} | |
| 55 | - {tree.entries.map((entry: any) => { | |
| 56 | - const fullPath = path ? `${path}/${entry.name}` : entry.name; | |
| 57 | - return ( | |
| 58 | - <tr style="border-bottom: 1px solid var(--border);"> | |
| 59 | - <td style="padding: 8px 16px; font-size: 0.875rem;"> | |
| 60 | - <span style="color: var(--text-muted); margin-right: 8px;"> | |
| 61 | - {entry.type === 'dir' ? '📁' : '📄'} | |
| 62 | - </span> | |
| 63 | - {entry.type === 'dir' ? ( | |
| 64 | - <a href={`/${owner}/${repo}/tree/${ref}/${fullPath}`}>{entry.name}</a> | |
| 65 | - ) : ( | |
| 66 | - <a href={`/${owner}/${repo}/blob/${ref}/${fullPath}`}>{entry.name}</a> | |
| 67 | - )} | |
| 68 | - </td> | |
| 69 | - </tr> | |
| 70 | - ); | |
| 71 | - })} | |
| 72 | - </tbody> | |
| 73 | - </table> | |
| 74 | - </div> | |
| 75 | - )} | |
| 76 | -</Repo> | |
| 77 | - | |
M packages/worker/src/handlers/api/browse.ts
| @@ -31,7 +31,51 @@ | ||
| 31 | 31 | return { repo, browseService, refStore }; |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | -/** GET /api/repos/:owner/:repo/tree/:ref/:path* */ | |
| 34 | +/** | |
| 35 | + * Resolve a combined "ref/path" string into separate ref and path parts. | |
| 36 | + * Branch names can contain slashes (e.g. "feat/initial-implementation"), | |
| 37 | + * so we try matching against known branches from longest to shortest. | |
| 38 | + */ | |
| 39 | +async function resolveRefAndPath( | |
| 40 | + refStore: RefStore, | |
| 41 | + refAndPath: string, | |
| 42 | + defaultBranch: string | |
| 43 | +): Promise<{ ref: string; path: string }> { | |
| 44 | + if (!refAndPath) { | |
| 45 | + return { ref: defaultBranch, path: '' }; | |
| 46 | + } | |
| 47 | + | |
| 48 | + const segments = refAndPath.split('/'); | |
| 49 | + | |
| 50 | + // Try progressively longer prefixes as the ref name | |
| 51 | + // Start from longest (most specific) to shortest | |
| 52 | + const allRefs = await refStore.getAll(); | |
| 53 | + const branchNames = allRefs | |
| 54 | + .filter((r) => r.name.startsWith('refs/heads/')) | |
| 55 | + .map((r) => r.name.replace('refs/heads/', '')); | |
| 56 | + const tagNames = allRefs | |
| 57 | + .filter((r) => r.name.startsWith('refs/tags/')) | |
| 58 | + .map((r) => r.name.replace('refs/tags/', '')); | |
| 59 | + const allNames = new Set([...branchNames, ...tagNames]); | |
| 60 | + | |
| 61 | + // Try from longest possible ref to shortest | |
| 62 | + for (let i = segments.length; i >= 1; i--) { | |
| 63 | + const candidate = segments.slice(0, i).join('/'); | |
| 64 | + if (allNames.has(candidate)) { | |
| 65 | + return { ref: candidate, path: segments.slice(i).join('/') }; | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + // Also check if it's a raw SHA | |
| 70 | + if (/^[0-9a-f]{40}$/.test(segments[0])) { | |
| 71 | + return { ref: segments[0], path: segments.slice(1).join('/') }; | |
| 72 | + } | |
| 73 | + | |
| 74 | + // Fall back to first segment as ref | |
| 75 | + return { ref: segments[0], path: segments.slice(1).join('/') }; | |
| 76 | +} | |
| 77 | + | |
| 78 | +/** GET /api/repos/:owner/:repo/tree/:refAndPath* */ | |
| 35 | 79 | export async function handleApiTree( |
| 36 | 80 | request: Request, |
| 37 | 81 | hctx: HandlerContext |
| @@ -39,14 +83,16 @@ | ||
| 39 | 83 | const ctx = await getBrowseContext(request, hctx); |
| 40 | 84 | if (!ctx) return Response.json({ error: 'Not found' }, { status: 404 }); |
| 41 | 85 | |
| 42 | - const { ref, path } = hctx.params; | |
| 43 | - const commitSha = await ctx.browseService.resolveRef(ref || ctx.repo.default_branch); | |
| 44 | - const entries = await ctx.browseService.listTree(commitSha, path || ''); | |
| 86 | + const { ref, path } = await resolveRefAndPath( | |
| 87 | + ctx.refStore, hctx.params.refAndPath || '', ctx.repo.default_branch | |
| 88 | + ); | |
| 89 | + const commitSha = await ctx.browseService.resolveRef(ref); | |
| 90 | + const entries = await ctx.browseService.listTree(commitSha, path); | |
| 45 | 91 | |
| 46 | - return Response.json({ ref, commit: commitSha, path: path || '', entries }); | |
| 92 | + return Response.json({ ref, commit: commitSha, path, entries }); | |
| 47 | 93 | } |
| 48 | 94 | |
| 49 | -/** GET /api/repos/:owner/:repo/blob/:ref/:path* */ | |
| 95 | +/** GET /api/repos/:owner/:repo/blob/:refAndPath* */ | |
| 50 | 96 | export async function handleApiBlob( |
| 51 | 97 | request: Request, |
| 52 | 98 | hctx: HandlerContext |
| @@ -54,10 +100,12 @@ | ||
| 54 | 100 | const ctx = await getBrowseContext(request, hctx); |
| 55 | 101 | if (!ctx) return Response.json({ error: 'Not found' }, { status: 404 }); |
| 56 | 102 | |
| 57 | - const { ref, path } = hctx.params; | |
| 103 | + const { ref, path } = await resolveRefAndPath( | |
| 104 | + ctx.refStore, hctx.params.refAndPath || '', ctx.repo.default_branch | |
| 105 | + ); | |
| 58 | 106 | if (!path) return Response.json({ error: 'Path required' }, { status: 400 }); |
| 59 | 107 | |
| 60 | - const commitSha = await ctx.browseService.resolveRef(ref || ctx.repo.default_branch); | |
| 108 | + const commitSha = await ctx.browseService.resolveRef(ref); | |
| 61 | 109 | const { content, sha } = await ctx.browseService.getBlob(commitSha, path); |
| 62 | 110 | |
| 63 | 111 | // Check if content is likely text |
| @@ -89,7 +137,7 @@ | ||
| 89 | 137 | } |
| 90 | 138 | } |
| 91 | 139 | |
| 92 | -/** GET /api/repos/:owner/:repo/commits/:ref */ | |
| 140 | +/** GET /api/repos/:owner/:repo/commits/:ref (ref may contain slashes) */ | |
| 93 | 141 | export async function handleApiCommits( |
| 94 | 142 | request: Request, |
| 95 | 143 | hctx: HandlerContext |
| @@ -97,14 +145,14 @@ | ||
| 97 | 145 | const ctx = await getBrowseContext(request, hctx); |
| 98 | 146 | if (!ctx) return Response.json({ error: 'Not found' }, { status: 404 }); |
| 99 | 147 | |
| 100 | - const { ref } = hctx.params; | |
| 148 | + const refName = hctx.params.ref || ctx.repo.default_branch; | |
| 101 | 149 | const url = new URL(request.url); |
| 102 | 150 | const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100); |
| 103 | 151 | |
| 104 | - const commitSha = await ctx.browseService.resolveRef(ref || ctx.repo.default_branch); | |
| 152 | + const commitSha = await ctx.browseService.resolveRef(refName); | |
| 105 | 153 | const commits = await ctx.browseService.getCommitLog(commitSha, limit); |
| 106 | 154 | |
| 107 | - return Response.json({ ref, commits }); | |
| 155 | + return Response.json({ ref: refName, commits }); | |
| 108 | 156 | } |
| 109 | 157 | |
| 110 | 158 | /** GET /api/repos/:owner/:repo/commit/:sha */ |
M packages/worker/src/router.ts
| @@ -96,19 +96,19 @@ | ||
| 96 | 96 | // REST API - Browsing |
| 97 | 97 | { |
| 98 | 98 | method: 'GET', |
| 99 | - pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/tree\/([^/]+)(?:\/(.+))?$/, | |
| 100 | - paramNames: ['owner', 'repo', 'ref', 'path'], | |
| 99 | + pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/tree\/(.+)$/, | |
| 100 | + paramNames: ['owner', 'repo', 'refAndPath'], | |
| 101 | 101 | handler: handleApiTree, |
| 102 | 102 | }, |
| 103 | 103 | { |
| 104 | 104 | method: 'GET', |
| 105 | - pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/, | |
| 106 | - paramNames: ['owner', 'repo', 'ref', 'path'], | |
| 105 | + pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/blob\/(.+)$/, | |
| 106 | + paramNames: ['owner', 'repo', 'refAndPath'], | |
| 107 | 107 | handler: handleApiBlob, |
| 108 | 108 | }, |
| 109 | 109 | { |
| 110 | 110 | method: 'GET', |
| 111 | - pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/commits\/([^/]+)$/, | |
| 111 | + pattern: /^\/api\/repos\/([^/]+)\/([^/]+)\/commits\/(.+)$/, | |
| 112 | 112 | paramNames: ['owner', 'repo', 'ref'], |
| 113 | 113 | handler: handleApiCommits, |
| 114 | 114 | }, |