✨ feat(vscode): 注释里的仓库图片按比例显示

This commit is contained in:
cruldra committed 2026-10-03 03:22:20 +08:00
1 parent 57b2f2a115
commit 9faf9cde9e
13 files changed
+312 -8

No files matched your search

+4
View File
@@ -98,6 +98,7 @@ export interface QaMessage {
* repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
*/
export type ExtensionToWebview
= | DocumentMessage
@@ -117,6 +118,7 @@ export type ExtensionToWebview
| { type: 'repoLinksChecked', id: number, missing: string[] }
| { type: 'repoLinkBroken', path: string, symbol?: string }
| { type: 'repoLinkOpened', path: string, symbol?: string }
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
@@ -138,6 +140,7 @@ export type ExtensionToWebview
* 扩展端按它设 when 子句用的上下文键
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在资源管理器里定位目录
* checkRepoLinks:预览里这批仓库路径,问哪些文件或目录没有。id 标出是哪一次问
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
*/
export type WebviewToExtension
= | { type: 'ready' }
@@ -158,3 +161,4 @@ export type WebviewToExtension
| { type: 'occurrenceNavigation', available: boolean }
| { type: 'openRepoLink', path: string, symbol?: string }
| { type: 'checkRepoLinks', id: number, paths: string[] }
| { type: 'checkRepoImages', id: number, paths: string[] }
+49 -2
View File
@@ -12,6 +12,7 @@ import type { IndexedEntry } from '../workspaceIndex'
import type { BlockFilter } from './blockFilter'
import type { Definition, FoundLocation } from './definitions'
import type { ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
import { realpath } from 'node:fs/promises'
import { dirname } from 'node:path'
import { commands, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
@@ -32,7 +33,7 @@ import { DefinitionSources } from './definitionSources'
import { documentMessage } from './documentMessage'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { occurrencesOf } from './occurrences'
import { findRepoSymbol, repoRootOf, resolveRepoEntry } from './repoPath'
import { findRepoSymbol, isImageFile, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath'
export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
@@ -498,6 +499,9 @@ class Preview {
case 'checkRepoLinks':
await this.checkRepoLinks(msg.id, msg.paths)
return
case 'checkRepoImages':
await this.checkRepoImages(msg.id, msg.paths)
return
case 'occurrenceNavigation':
this.owner.navigation.update(this, msg.available)
}
@@ -580,6 +584,45 @@ class Preview {
this.post({ type: 'repoLinksChecked', id, missing })
}
/** 仓库根放进这个预览能读的本地目录,图片地址才能加载。真实位置和 git 给的路径不一样时两个都放。 */
private addLocalRoot(fsPath: string): void {
const uri = Uri.file(fsPath)
const roots = this.panel.webview.options.localResourceRoots ?? []
if (roots.some(root => root.fsPath === uri.fsPath))
return
this.panel.webview.options = { ...this.panel.webview.options, localResourceRoots: [...roots, uri] }
}
private async allowRepoRoot(root: string): Promise<void> {
this.addLocalRoot(root)
try {
const real = await realpath(root)
if (real !== root)
this.addLocalRoot(real)
}
catch {
// 仓库根这会儿读不到真实位置,就按 git 给的路径放行
}
}
/** 这批仓库图片哪些在。在的带回预览里能直接用的地址;不在、不是图片、跑出仓库的没有地址。 */
private async checkRepoImages(id: number, paths: string[]): Promise<void> {
const root = await repoRootOf(this.document.uri.fsPath)
if (root)
await this.allowRepoRoot(root)
const images = await Promise.all(paths.map(async (rel) => {
if (root === undefined || !isRepoImagePath(rel))
return { path: rel }
const entry = await resolveRepoEntry(root, rel)
if (!entry || entry.isDirectory)
return { path: rel }
const src = this.panel.webview.asWebviewUri(Uri.file(entry.fsPath)).toString()
return { path: rel, src }
}))
if (this.ready)
this.post({ type: 'repoImagesChecked', id, images })
}
/**
* 打开注释里的仓库链接:目录在资源管理器里定位,文件用编辑器打开。
* 带了符号就按文档符号的名字跳过去;文件或符号没有就不打开。
@@ -629,10 +672,14 @@ class Preview {
return Array.isArray(symbols) ? findRepoSymbol(symbols, symbol) : undefined
}
/** 在当前这个预览所在的编辑器组打开文件,并把预览标签固定住,免得被它替掉。 */
/** 在预览所在的编辑器组打开文件,并把预览标签固定住,免得被它替掉。图片用默认打开方式,才看得到图。 */
private async showRepoFile(uri: Uri, selection?: Range): Promise<void> {
if (this.panel.active && !workspace.getConfiguration('workbench.editor').get<boolean>('enablePreviewFromCodeNavigation', false))
await commands.executeCommand('workbench.action.keepEditor')
if (selection === undefined && isImageFile(uri.fsPath)) {
await commands.executeCommand('vscode.open', uri, { viewColumn: this.panel.viewColumn })
return
}
await window.showTextDocument(uri, { viewColumn: this.panel.viewColumn, ...(selection && { selection }) })
}
+13 -1
View File
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promis
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { findRepoSymbol, pathInsideRoot, resolveRepoEntry } from './repoPath'
import { findRepoSymbol, isRepoImagePath, pathInsideRoot, resolveRepoEntry } from './repoPath'
describe('pathInsideRoot', () => {
const root = '/repo'
@@ -21,6 +21,18 @@ describe('pathInsideRoot', () => {
})
})
describe('isRepoImagePath', () => {
it('仓库里的图片算,跑出去的和不是图片的不算', () => {
expect(isRepoImagePath('frontend/a.png')).toBe(true)
expect(isRepoImagePath('frontend/a.PNG')).toBe(true)
expect(isRepoImagePath('../a.png')).toBe(false)
expect(isRepoImagePath('./a.png')).toBe(false)
expect(isRepoImagePath('/a.png')).toBe(false)
expect(isRepoImagePath('C:/a.png')).toBe(false)
expect(isRepoImagePath('README.md')).toBe(false)
})
})
describe('findRepoSymbol', () => {
const tree = [
{
+13
View File
@@ -30,6 +30,19 @@ export function isSafeRepoPath(rel: string): boolean {
return segments.length > 0 && segments.every(segment => segment !== '' && segment !== '.' && segment !== '..')
}
/** 注释里当图片显示的文件后缀。webview 的 repoImage.ts 用同一份。 */
const IMAGE_FILE = /\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i
/** 文件名是图片。绝对路径也能用来判断,不检查有没有跑出仓库。 */
export function isImageFile(filePath: string): boolean {
return IMAGE_FILE.test(filePath)
}
/** 从仓库根起、能在注释里当图片显示的路径。别的仓库文件不给预览地址。 */
export function isRepoImagePath(rel: string): boolean {
return isSafeRepoPath(rel) && isImageFile(rel)
}
/** target 解析之后还在 root 里面(root 自己不算)。 */
export function staysInside(root: string, target: string): boolean {
const back = path.relative(path.resolve(root), path.resolve(target))