Skip to content
Clone

fix(repo-browse): give in-repo links an absolute app URL

A srcdoc iframe has no URL of its own - it inherits the parent
document's base - so GitHub-style repo-relative hrefs (`../README.md`,
`docs/`) resolve against /app/<space>/repo/<repo>: "copy link address"
and middle-click shipped /app/minimax/repo/CHANGES.md, whose last
segment the router reads as the repository name.

prepareReadmeHtml now re-anchors every <a href> through the tree lookup
it already performs: a folder becomes repoWebHref(..., path=...), a blob
becomes repoWebHref(..., file=...), and a target that exists in neither
form keeps the author's text with data-missing rather than inventing a
URL that 404s differently. repoWebHref/parseRepoAppHref are the pair the
host uses to turn such a URL back into a route, and code-tab short
circuits on the rewritten `file=` vs `path=` instead of re-querying the
tree; anything it cannot resolve still falls through to the old path.

entities/repo also gains listSpaceRepos(): the issue-detail pull-request
picker addresses repositories by name and cannot work against a single
page, so it asks for the whole visible list in one name-sorted call.
ZhenYi committed
parent 8724f83 commit 2a17880 Browse files

5 files changed

+126 −3

File tree 5
Modified web/src/entities/repo/model/href.ts View web/src/entities/repo/model/href.ts
@@ -19,6 +19,62 @@
1919 return path.split('/').slice(0, -1).join('/')
2020}
2121
22/**
23 * Canonical in-app URL for a repo file, folder or any other repo view.
24 *
25 * GitHub-style README links are repo-relative (`../README.md`, `docs/`), but a
26 * `srcdoc` iframe has no URL of its own — it inherits the *parent document's*
27 * base, so the browser resolves them against `/app/<space>/repo/<repo>` and a
28 * copied or middle-clicked link loses the repo: `CHANGES.md` becomes
29 * `/app/minimax/repo/CHANGES.md`, whose last segment the router reads as the
30 * repo name. Rewriting every in-repo link to this absolute shape is what keeps
31 * "open in new tab" and "copy link address" honest.
32 */
33export function repoWebHref(
34 space: string,
35 repo: string,
36 parts: { path?: string; file?: string; rev?: string; tab?: string; extra?: Record<string, string> },
37): string {
38 const params = new URLSearchParams()
39 if (parts.rev) params.set('rev', parts.rev)
40 if (parts.tab && parts.tab !== 'code') params.set('tab', parts.tab)
41 if (parts.path) params.set('path', parts.path)
42 if (parts.file) params.set('file', parts.file)
43 for (const [key, value] of Object.entries(parts.extra ?? {})) params.set(key, value)
44 const query = params.toString()
45 const base = `/app/${encodeURIComponent(space)}/repo/${encodeURIComponent(repo)}`
46 return query ? `${base}?${query}` : base
47}
48
49/**
50 * Inverse of `repoWebHref`: read back an in-app repo URL produced by it.
51 *
52 * The README sandbox forwards its clicks to the host, and after link rewriting
53 * those hrefs are absolute app URLs rather than repo-relative paths, so the
54 * host needs to turn one back into a route. Returns null for any other URL
55 * (external site, another repo, a non-repo route).
56 */
57export function parseRepoAppHref(
58 href: string,
59 space: string,
60 repo: string,
61): { path?: string; file?: string; rev?: string; tab?: string } | null {
62 const prefix = `/app/${encodeURIComponent(space)}/repo/${encodeURIComponent(repo)}`
63 const [pathname, search = ''] = href.split('?')
64 if (pathname !== prefix) return null
65 const params = new URLSearchParams(search)
66 const out: { path?: string; file?: string; rev?: string; tab?: string } = {}
67 const path = params.get('path')
68 const file = params.get('file')
69 const rev = params.get('rev')
70 const tab = params.get('tab')
71 if (path) out.path = path
72 if (file) out.file = file
73 if (rev) out.rev = rev
74 if (tab) out.tab = tab
75 return out
76}
77
2278export function parseWebHref(href: string, space: string, repo: string, rev: string): WebTarget {
2379 const clean = href.split('#')[0].split('?')[0]
2480 const prefix = `/${space}/${repo}`
Modified web/src/entities/repo/model/repo.ts View web/src/entities/repo/model/repo.ts
@@ -3,10 +3,34 @@
33 */
44import Apis from '@/shared/api'
55import { unwrapApi } from '@/shared/lib'
6import type { AppResponseData_RepoDto, UpdateRepoCmd } from '@/shared/api'
6import type {
7 AppResponseData_Page_RepoSummaryDto,
8 AppResponseData_RepoDto,
9 UpdateRepoCmd,
10} from '@/shared/api'
711
812export type RepoDetail = NonNullable<AppResponseData_RepoDto['data']>
913
14/** Row of the space's repo list (`GET /repos/{space}`). */
15export type RepoSummary = NonNullable<
16 NonNullable<AppResponseData_Page_RepoSummaryDto['data']>['items']
17>[number]
18
19/**
20 * Every repo the viewer can see in one space, name-sorted.
21 *
22 * The endpoint paginates, but the pickers that need this list (linking a PR to
23 * an issue, choosing an import target) address a repo by name and cannot work
24 * against a page, so the whole list is requested in one go.
25 */
26export const listSpaceRepos = (space: string) =>
27 unwrapApi<{ items?: RepoSummary[] }>(
28 Apis.repos.list_repos({
29 pathParams: { space_name: space },
30 params: { limit: 100, sort: 'name' },
31 }),
32 ).then(page => page?.items ?? [])
33
1034export const fetchRepo = (space: string, repo: string) =>
1135 unwrapApi<RepoDetail>(
1236 Apis.repos.get_repo({ pathParams: { space_name: space, repo_name: repo } }),
Modified web/src/entities/repo/index.ts View web/src/entities/repo/index.ts
@@ -14,6 +14,7 @@
1414// repo metadata + clone urls
1515export {
1616 fetchRepo,
17 listSpaceRepos,
1718 updateRepo,
1819 archiveRepo,
1920 unarchiveRepo,
@@ -23,6 +24,7 @@
2324 primaryCloneUrl,
2425 type CloneTarget,
2526 type RepoDetail,
27 type RepoSummary,
2628} from './model/repo'
2729
2830// tree & blob
@@ -138,7 +140,7 @@
138140 useHtmlFragment,
139141 type HtmlFragment,
140142} from './model/fragment'
141export { parseWebHref, parentPath } from './model/href'
143export { parseWebHref, parseRepoAppHref, parentPath, repoWebHref } from './model/href'
142144
143145// protection rules (branch policies + whitelists)
144146export {
Modified web/src/features/repo-browse/model/readme.ts View web/src/features/repo-browse/model/readme.ts
@@ -15,7 +15,7 @@
1515 */
1616import { marked } from 'marked'
1717
18import { fetchBlob, fetchTreeJson } from '@/entities/repo'
18import { fetchBlob, fetchTreeJson, repoWebHref } from '@/entities/repo'
1919
2020/** Anything bigger is not worth base64-ing into an attribute. */
2121const MAX_INLINE_BYTES = 4 * 1024 * 1024
@@ -219,6 +219,37 @@
219219 }),
220220 )
221221
222 /**
223 * Re-anchor in-repo links onto the app's own URLs.
224 *
225 * The frame is `srcdoc`, so it has no base of its own: the browser resolves
226 * `CHANGES.md` or `../docs/x.md` against `/app/<space>/repo/<repo>`, and the
227 * anchor's own href is what "open in new tab" / "copy link address" use. The
228 * click proxy already re-routes the *primary* click, but leaving the raw
229 * relative href behind ships a broken URL to every other affordance. Each
230 * link is resolved against the repo tree, so a folder opens the folder and a
231 * file opens the file — matching what the proxy would do with that click.
232 */
233 const links = Array.from(root?.querySelectorAll('a[href]') ?? [])
234 await Promise.all(
235 links.map(async anchor => {
236 const href = anchor.getAttribute('href')
237 if (!href) return
238 const target = resolveRepoPath(baseDir, href)
239 if (!target) return
240 const entry = await statRepoPath(space, repo, rev, target)
241 if (entry.kind === 'tree') {
242 anchor.setAttribute('href', repoWebHref(space, repo, { rev, path: target }))
243 } else if (entry.kind === 'blob') {
244 anchor.setAttribute('href', repoWebHref(space, repo, { rev, file: target }))
245 } else {
246 // Nothing at that path in this revision: leave the author's text alone
247 // rather than invent a URL that 404s a different way.
248 anchor.setAttribute('data-missing', target)
249 }
250 }),
251 )
252
222253 return root?.innerHTML ?? ''
223254}
224255
Modified web/src/features/repo-browse/ui/code-tab.tsx View web/src/features/repo-browse/ui/code-tab.tsx
@@ -12,6 +12,7 @@
1212 primaryCloneUrl,
1313 useHtmlFragment,
1414 type RepoDetail,
15 parseRepoAppHref,
1516} from '@/entities/repo'
1617import { isMarkdownPath, isPdfPath } from '../model/blob-kind'
1718import { prepareMarkdownHtml, prepareReadmeHtml, resolveRepoPath, statRepoPath } from '../model/readme'
@@ -513,6 +514,15 @@
513514 const next = Math.min(Math.max(Math.round(data.height as number) + 2, 80), 40000)
514515 setHeight(current => (current === next ? current : next))
515516 } else if (data.kind === 'link' && typeof data.href === 'string') {
517 // Rewritten links carry the answer already (`file=` vs `path=`), so no
518 // tree lookup is needed to tell a file from a folder.
519 const appRoute = parseRepoAppHref(data.href, routes.space, routes.repo)
520 if (appRoute) {
521 if (appRoute.file) routes.onOpenFile({ path: appRoute.file })
522 else if (appRoute.path) routes.onNavigate(appRoute.path)
523 return
524 }
525 // A relative href the rewriter could not resolve (e.g. no blob oid).
516526 const target = resolveRepoPath(baseDir, data.href)
517527 if (target) {
518528 void (async () => {