✨ feat(vscode): 代码阅读预览里工作区外的库文件也能问 AI,问答记进第一个工作区文件夹,重写注释不对库文件开放

This commit is contained in:
cruldra committed 2026-10-13 21:50:54 +11:00
1 parent cecae0e244
commit 39dfd9b084
24 files changed
+463 -79

No files matched your search

+3 -1
View File
@@ -150,8 +150,10 @@ export interface QaThreadView {
*/
export interface QaMessage {
type: 'qa'
/** 不在工作区文件夹里时没地方存,问 AI、重写注释不可用 */
/** 能问 AI:问答有地方存(工作区外的库文件记在第一个工作区文件夹);没打开工作区文件夹时不能 */
available: boolean
/** 能重写注释:只有工作区里的文件,工作区外的库文件不改 */
rewritable: boolean
/** 块下面的问答,按块的记号 */
threads: QaThreadView[]
/** 这一页顶部、按路径记的问答;不挂在块上 */
@@ -0,0 +1,35 @@
import type { Uri } from 'vscode'
import { describe, expect, it, vi } from 'vitest'
import { pathQaLocation, topQaLocation } from './pathPreview'
const places = vi.hoisted(() => {
const folder = { uri: { scheme: 'file', path: '/ws/app', fsPath: '/ws/app', toString: () => 'file:///ws/app' }, name: 'app', index: 0 }
/** ⌘ 点进去的标准库源码,不在工作区里 */
const library = { scheme: 'file', query: '', path: '/Users/me/.local/lib/python3.12/pathlib.py', fsPath: '/Users/me/.local/lib/python3.12/pathlib.py' }
/** locateQa 给它的位置:第一个工作区文件夹,键是 ~/… */
const outside = { folder, key: '~/.local/lib/python3.12/pathlib.py', promptPath: library.fsPath, outsideDir: '/Users/me/.local/lib/python3.12' }
return { folder, library, outside }
})
vi.mock('vscode', () => ({
Uri: {},
workspace: {
workspaceFolders: [places.folder],
getWorkspaceFolder: (uri: { path: string }) => uri.path === '/ws/app' || uri.path.startsWith('/ws/app/') ? places.folder : undefined,
},
}))
vi.mock('../folderStore', () => ({ locateQa: (uri: { path: string }) => uri.path === places.library.path ? places.outside : undefined }))
describe('顶部问答记在哪', () => {
it('工作区外的库文件也能在顶部问:和块下面的问答同一个定位(第一个工作区文件夹 + ~/… 键)', () => {
const library = places.library as unknown as Uri
expect(pathQaLocation(library)).toEqual(places.outside)
expect(topQaLocation(library)).toEqual({ folder: places.folder, key: 'file:///ws/app\n~/.local/lib/python3.12/pathlib.py' })
})
it('工作区根本身也能问:键是空字符串,发给模型也是空的(写成 .)', () => {
const root = { scheme: 'file', query: '', path: '/ws/app', fsPath: '/ws/app' } as unknown as Uri
expect(pathQaLocation(root)).toEqual({ folder: places.folder, key: '', promptPath: '' })
expect(topQaLocation(root)).toEqual({ folder: places.folder, key: 'file:///ws/app\n' })
})
})
+10 -8
View File
@@ -5,8 +5,10 @@
*/
import type { WorkspaceFolder } from 'vscode'
import type { QaLocation } from '../folderStore'
import * as path from 'node:path'
import { Uri, workspace } from 'vscode'
import { locateQa } from '../folderStore'
import { featureQaKey, pathQaKey, relativeQaKey } from '../qa/pathQa'
export const PATH_PREVIEW_SCHEME = 'code-reader-path'
@@ -56,20 +58,20 @@ export function topSubjectOf(uri: Uri): { uri: Uri, kind: TopSubject } {
}
/**
* 这条路径在哪个工作区文件夹、相对路径是什么。
* 工作区根本身的相对路径是空字符串(locate 不认根,顶部问答要认)。
* 这条路径的顶部问答记在哪、发给模型时怎么写它:同 [locateQa](工作区外的库文件也能问),
* 另外认工作区根本身,键和发给模型的路径都是空字符串(locate 不认根,顶部问答要认)。
*/
export function pathQaLocation(uri: Uri): { folder: WorkspaceFolder, key: string } | undefined {
export function pathQaLocation(uri: Uri): QaLocation | undefined {
const folder = workspace.getWorkspaceFolder(uri)
if (!folder)
return undefined
const key = relativeQaKey(path.posix.normalize(folder.uri.path), path.posix.normalize(uri.path))
return key === undefined ? undefined : { folder, key }
if (folder && relativeQaKey(path.posix.normalize(folder.uri.path), path.posix.normalize(uri.path)) === '')
return { folder, key: '', promptPath: '' }
return locateQa(uri)
}
/**
* 顶部问答记在哪:在哪个工作区文件夹(claude 在它里面跑,带的图片存在它的 `.spx/code-qa/`)、记录表里的键(见 pathQa.ts)。
* 文件、文件夹按「工作区文件夹 + 相对路径」;功能对话页按功能的 id,记在第一个工作区文件夹。不在工作区里、没打开文件夹时没有。
* 文件、文件夹按「工作区文件夹 + 键」(见 [pathQaLocation],工作区外的库文件记在第一个工作区文件夹);功能对话页按功能的 id,记在第一个工作区文件夹。
* 没打开文件夹时没有。
*/
export function topQaLocation(uri: Uri): { folder: WorkspaceFolder, key: string } | undefined {
const feature = featureIdOf(uri)
@@ -29,10 +29,11 @@ import { commentFollowUp, commentJob, commentRewritePrompt, findBlock, writeComm
import { DefinitionKinds } from '../definitionKinds'
import { PreviewDiff } from '../diff/previewDiff'
import { blockReplacement } from '../editText'
import { locate } from '../folderStore'
import { locate, locateQa } from '../folderStore'
import { LanguageRequests } from '../languageRequests'
import { definitionFor, symbolAt, symbolOccurrences } from '../localSymbols'
import { DocumentNames, resolveNames } from '../nameResolution'
import { NOT_REWRITABLE } from '../qa/codeQa'
import { PATH_ASK_ANCHOR } from '../qa/pathQa'
import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups'
@@ -622,8 +623,8 @@ class Preview {
this.postQa()
return
}
const at = locate(subject.uri)
const block = at && this.sent && { path: at.key, languageId: this.document.languageId, blocks: this.sent.blocks, source: this.sent.source, anchor }
const at = locateQa(subject.uri)
const block = at && this.sent && { path: at.promptPath, languageId: this.document.languageId, blocks: this.sent.blocks, source: this.sent.source, anchor }
const prompt = block && askPrompt(block, question)
const followUp = block && followUpPrompt(block, question)
const refused = prompt === undefined || followUp === undefined
@@ -1075,7 +1076,7 @@ class Preview {
}
const at = locate(document.uri)
if (!at) {
fail('这个文件不在工作区文件夹里,重写记录没地方存。')
fail(NOT_REWRITABLE)
return
}
if (document.isDirty) {
+88
View File
@@ -0,0 +1,88 @@
import type { WorkspaceFolder } from 'vscode'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { keyUri, locateQa } from './folderStore'
const fake = vi.hoisted(() => {
/** 够用的 Uri:协议、主机、路径;测试里都是 POSIX 路径,fsPath 同 path */
class FakeUri {
constructor(readonly scheme: string, readonly authority: string, readonly path: string) {}
get fsPath(): string {
return this.path
}
with(change: { path?: string }): FakeUri {
return new FakeUri(this.scheme, this.authority, change.path ?? this.path)
}
toString(): string {
return `${this.scheme}://${this.authority}${this.path}`
}
static file(fsPath: string): FakeUri {
return new FakeUri('file', '', fsPath)
}
static joinPath(base: FakeUri, ...parts: string[]): FakeUri {
return base.with({ path: [base.path, ...parts].join('/') })
}
}
/** 眼下打开的工作区文件夹,按顺序 */
const folders: WorkspaceFolder[] = []
const workspace = {
get workspaceFolders(): WorkspaceFolder[] | undefined {
return folders.length > 0 ? folders : undefined
},
getWorkspaceFolder: (uri: FakeUri) => folders.find(folder => uri.path === folder.uri.path || uri.path.startsWith(`${folder.uri.path}/`)),
}
return { FakeUri, folders, workspace }
})
vi.mock('vscode', () => ({ Uri: fake.FakeUri, workspace: fake.workspace, Disposable: {}, EventEmitter: class {}, RelativePattern: class {}, window: {} }))
vi.mock('node:os', () => ({ homedir: () => '/Users/me' }))
const { FakeUri } = fake
const folder = (path: string, index: number): WorkspaceFolder => ({ uri: FakeUri.file(path) as never, name: path.split('/').at(-1)!, index })
const APP = folder('/Users/me/work/app', 0)
const API = folder('/Users/me/work/api', 1)
/** uv 装的 Python 标准库,⌘ 点 rglob 进去的那份 */
const LIBRARY_DIR = '/Users/me/.local/share/uv/python/cpython-3.12.4-macos-aarch64-none/lib/python3.12'
const PATHLIB = FakeUri.file(`${LIBRARY_DIR}/pathlib.py`) as never
beforeEach(() => {
fake.folders.splice(0, fake.folders.length, APP, API)
})
describe('这份文件的问答记在哪', () => {
it('工作区里的文件:记在所在的工作区文件夹,键是相对路径;发给模型也写相对路径,不用另外放行目录', () => {
expect(locateQa(FakeUri.file('/Users/me/work/api/src/main.py') as never)).toEqual({ folder: API, key: 'src/main.py', promptPath: 'src/main.py' })
})
it('家目录下的工作区外文件(库源码):记在第一个工作区文件夹,键把家目录写成 ~;发给模型写绝对路径,放行它所在的目录', () => {
expect(locateQa(PATHLIB)).toEqual({
folder: APP,
key: '~/.local/share/uv/python/cpython-3.12.4-macos-aarch64-none/lib/python3.12/pathlib.py',
promptPath: `${LIBRARY_DIR}/pathlib.py`,
outsideDir: LIBRARY_DIR,
})
})
it('家目录外的工作区外文件:键是绝对路径', () => {
const requests = FakeUri.file('/opt/homebrew/lib/python3.12/site-packages/requests/api.py') as never
expect(locateQa(requests)).toMatchObject({ folder: APP, key: '/opt/homebrew/lib/python3.12/site-packages/requests/api.py', outsideDir: '/opt/homebrew/lib/python3.12/site-packages/requests' })
})
it('没打开工作区文件夹时没有;工作区文件夹本身、不是磁盘上的文件(别的协议)也没有', () => {
expect(locateQa(APP.uri)).toBeUndefined()
expect(locateQa(new FakeUri('git', '', `${LIBRARY_DIR}/pathlib.py`) as never)).toBeUndefined()
fake.folders.splice(0)
expect(locateQa(PATHLIB)).toBeUndefined()
})
})
describe('表里的键对应哪个文件', () => {
it('工作区里的在工作区文件夹下面;工作区外的还原成磁盘上的位置,和打开的那份文件是同一个 Uri', () => {
expect(keyUri(API, 'src/main.py').toString()).toBe('file:///Users/me/work/api/src/main.py')
expect(keyUri(APP, locateQa(PATHLIB)!.key).toString()).toBe(String(PATHLIB))
expect(keyUri(APP, '/opt/homebrew/a.py').toString()).toBe('file:///opt/homebrew/a.py')
})
})
+39 -2
View File
@@ -1,7 +1,9 @@
import type { FileDeleteEvent, FileRenameEvent, WorkspaceFolder } from 'vscode'
import type { PathMap, PathMapFile } from './pathMap'
import { homedir } from 'node:os'
import * as path from 'node:path'
import { Disposable, EventEmitter, RelativePattern, Uri, window, workspace } from 'vscode'
import { changedKeys, detachedFiles, dropEntries, entriesUnder, moveEntries, placeEntries, readPathMap, removeFiles, toPathKey, writePathMap } from './pathMap'
import { changedKeys, detachedFiles, dropEntries, entriesUnder, moveEntries, outsideKey, outsidePath, placeEntries, readPathMap, removeFiles, toPathKey, writePathMap } from './pathMap'
/** 一个文件 / 目录在哪个工作区文件夹里、键是什么。 */
export interface PathLocation {
@@ -16,6 +18,41 @@ export function locate(uri: Uri): PathLocation | undefined {
return folder && key !== undefined ? { folder, key } : undefined
}
/** 一份文件的问答记在哪(见 [locateQa]),和问的时候怎么跟模型说这份文件。 */
export interface QaLocation extends PathLocation {
/** 发给模型时这份文件写成什么:工作区里的写相对路径(同 key),工作区外的写绝对路径 */
promptPath: string
/** 工作区外的文件所在的目录:claude 在 folder 里跑,要另外放行读这个目录;工作区里的文件没有 */
outsideDir?: string
}
/** 家目录的 `Uri.path`:工作区外的文件在它下面时,键写成 `~/…`(见 [outsideKey])。 */
function homePath(): string {
return Uri.file(homedir()).path
}
/**
* 一份文件的问答记在哪。工作区里的同 [locate]。
* 工作区外的(比如 ⌘ 点进去的库源码)记在第一个工作区文件夹,固定用第一个,同一个库文件每次都落在同一处;键见 [outsideKey]。
* 没打开工作区文件夹、`uri` 就是工作区文件夹本身、`uri` 不是工作区里那种文件(比如 `git:` 的)时返回 `undefined`。
*/
export function locateQa(uri: Uri): QaLocation | undefined {
if (workspace.getWorkspaceFolder(uri)) {
const at = locate(uri)
return at && { ...at, promptPath: at.key }
}
const folder = workspace.workspaceFolders?.[0]
if (!folder || uri.scheme !== folder.uri.scheme || uri.authority !== folder.uri.authority)
return undefined
return { folder, key: outsideKey(uri.path, homePath()), promptPath: uri.fsPath, outsideDir: path.dirname(uri.fsPath) }
}
/** 表里的 `key` 指的那个文件 / 目录:工作区外的键(`~/…`、绝对路径)还原成磁盘上的位置,别的在 `folder` 下面。 */
export function keyUri(folder: WorkspaceFolder, key: string): Uri {
const outside = outsidePath(key, homePath())
return outside === undefined ? Uri.joinPath(folder.uri, ...key.split('/')) : folder.uri.with({ path: outside })
}
/**
* 每个工作区文件夹一份的按路径记的表(见 pathMap):内存里留一份给界面同步取用。
* 在 VS Code 里改名、挪位置,条目跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读。
@@ -130,7 +167,7 @@ export class FolderStore<T> implements Disposable {
const keys = changedKeys(this.maps.get(id) ?? {}, map)
this.maps.set(id, map)
if (keys.length > 0)
this.changed.fire(keys.map(key => Uri.joinPath(folder.uri, ...key.split('/'))))
this.changed.fire(keys.map(key => keyUri(folder, key)))
}
private enqueue(task: () => Promise<void>): Promise<void> {
+36 -1
View File
@@ -3,7 +3,7 @@ import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { changedKeys, detachedFiles, dropEntries, entriesUnder, moveEntries, normalizeKey, placeEntries, removeFiles, toPathKey } from './pathMap'
import { changedKeys, detachedFiles, dropEntries, entriesUnder, moveEntries, normalizeKey, outsideKey, outsidePath, placeEntries, removeFiles, tableKey, toPathKey } from './pathMap'
describe('路径的键', () => {
it('相对工作区文件夹、用 / 分隔', () => {
@@ -30,6 +30,41 @@ describe('路径的键', () => {
})
})
describe('工作区外的文件的键', () => {
const HOME = '/Users/me'
const PATHLIB = '/Users/me/.local/share/uv/python/cpython-3.12.4-macos-aarch64-none/lib/python3.12/pathlib.py'
it('家目录下的写成 ~/…,别处写绝对路径;名字只是前缀相同的不算家目录下', () => {
expect(outsideKey(PATHLIB, HOME)).toBe('~/.local/share/uv/python/cpython-3.12.4-macos-aarch64-none/lib/python3.12/pathlib.py')
expect(outsideKey('/opt/homebrew/lib/python3.12/site-packages/requests/api.py', HOME)).toBe('/opt/homebrew/lib/python3.12/site-packages/requests/api.py')
expect(outsideKey('/Users/meow/a.py', HOME)).toBe('/Users/meow/a.py')
// Windows 上 Uri.path 带盘符
expect(outsideKey('/c:/Users/me/AppData/a.py', '/c:/Users/me')).toBe('~/AppData/a.py')
})
it('反过来还原成磁盘上的路径;工作区里的相对路径键不是工作区外的', () => {
expect(outsidePath(outsideKey(PATHLIB, HOME), HOME)).toBe(PATHLIB)
expect(outsidePath('/opt/homebrew/a.py', HOME)).toBe('/opt/homebrew/a.py')
expect(outsidePath('src/a.ts', HOME)).toBeUndefined()
})
it('读表时认这两种键,规范成同一种写法;跑出家目录的 ~/../ 丢掉', () => {
expect(tableKey('~/.local/lib//pathlib.py')).toBe('~/.local/lib/pathlib.py')
expect(tableKey('/opt/homebrew/lib/')).toBe('/opt/homebrew/lib')
expect(tableKey('./src/a.ts')).toBe('src/a.ts')
expect(tableKey('~/../etc/passwd')).toBeUndefined()
expect(tableKey('/')).toBeUndefined()
expect(tableKey('../a.ts')).toBeUndefined()
})
it('工作区里的改名、删除、挪走碰不到工作区外的键,哪怕名字一样', () => {
const map = { '~/lib/a.py': '库', '/opt/lib/a.py': '库', 'lib/a.py': '项目', 'opt/lib/a.py': '项目' }
expect(dropEntries(map, 'lib')).toEqual({ '~/lib/a.py': '库', '/opt/lib/a.py': '库', 'opt/lib/a.py': '项目' })
expect(entriesUnder(map, 'opt')).toEqual({ 'lib/a.py': '项目' })
expect(moveEntries(map, 'opt', 'vendor')).toEqual({ '~/lib/a.py': '库', '/opt/lib/a.py': '库', 'lib/a.py': '项目', 'vendor/lib/a.py': '项目' })
})
})
describe('改名、挪位置', () => {
const notes = {
'campaign': '营销活动',
+35 -2
View File
@@ -1,6 +1,7 @@
/**
* 按文件 / 目录记东西的一份表(文件注释、代码问答):每个工作区文件夹一份,落盘到 `.spx` 里的一个 JSON 文件,
* 形状是 `{ "相对路径": 值 }`。键相对这个工作区文件夹、`/` 分隔;改名、挪位置、删除时整棵子树跟着走。
* 代码问答还记工作区外的库文件,键是 `~/…` 或绝对路径(见 [outsideKey])。
*/
import { promises as fsp } from 'node:fs'
@@ -31,6 +32,38 @@ export function toPathKey(rootPath: string, targetPath: string): string | undefi
return normalizeKey(path.posix.relative(rootPath, targetPath))
}
/**
* 工作区外的文件 / 目录(比如库源码)的键:在家目录下写成 `~/…`,别处写绝对路径。
* 工作区里的键是相对路径,开头不会是 `~/` 或 `/`,两边撞不上。
*
* @param targetPath 文件 / 目录的 `Uri.path`
* @param homePath 家目录的 `Uri.path`
*/
export function outsideKey(targetPath: string, homePath: string): string {
const underHome = toPathKey(homePath, targetPath)
return underHome === undefined ? path.posix.normalize(targetPath).replace(/\/+$/, '') : `~/${underHome}`
}
/** 工作区外的键(见 [outsideKey])在磁盘上的 `Uri.path`;工作区里的相对路径键返回 `undefined`。 */
export function outsidePath(key: string, homePath: string): string | undefined {
if (key.startsWith('~/'))
return path.posix.join(homePath, key.slice(2))
return key.startsWith('/') ? key : undefined
}
/** 读表时把一个键规范成统一写法:相对路径同 [normalizeKey],工作区外的 `~/…`、绝对路径(见 [outsideKey])也认;跑出家目录的、空的返回 `undefined`。 */
export function tableKey(raw: string): string | undefined {
if (raw.startsWith('~/')) {
const underHome = normalizeKey(raw.slice(2))
return underHome === undefined ? undefined : `~/${underHome}`
}
if (raw.startsWith('/')) {
const absolute = path.posix.normalize(raw).replace(/\/+$/, '')
return absolute === '' ? undefined : absolute
}
return normalizeKey(raw)
}
function isUnder(candidate: string, key: string): boolean {
return candidate === key || candidate.startsWith(`${key}/`)
}
@@ -68,7 +101,7 @@ function fileIn<T>(workspaceRoot: string, file: PathMapFile<T>): string {
return path.join(workspaceRoot, ...file.path.split('/'))
}
/** 读工作区文件夹里的这份表。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键不合法、值认不出的条目跳过。 */
/** 读工作区文件夹里的这份表。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键认不出(见 [tableKey])、值认不出的条目跳过。 */
export async function readPathMap<T>(workspaceRoot: string, file: PathMapFile<T>): Promise<PathMap<T>> {
let raw: string
try {
@@ -83,7 +116,7 @@ export async function readPathMap<T>(workspaceRoot: string, file: PathMapFile<T>
return {}
const map: PathMap<T> = {}
for (const [rawKey, rawValue] of Object.entries(parsed as Record<string, unknown>)) {
const key = normalizeKey(rawKey)
const key = tableKey(rawKey)
const value = file.parse(rawValue)
if (key !== undefined && value !== undefined)
map[key] = value
@@ -168,6 +168,21 @@ describe('问一次', () => {
expect(sent(calls[1]).text).toBe('完整的问题')
})
it('问工作区外的库文件:在工作区文件夹里跑,库文件所在的目录用 --add-dir 放行;追问、会话找不到重新问都一样', async () => {
const library = '/Users/me/.local/share/uv/python/cpython-3.12/lib/python3.12'
const { deps, calls } = fakeClaude([
{ lines: [], error: new ClaudeError('Claude 退出码非零 (1): No conversation found with session ID: s0', 'No conversation found with session ID: s0') },
{ lines: [result('重新答', 's2')] },
])
await askClaude(request({ cwd: '/work/app', addDirs: [library], followUp: { sessionId: 's0', prompt: '短问题' } }), deps)
for (const call of calls) {
expect(call.cwd).toBe('/work/app')
expect(call.args.slice(call.args.indexOf('--add-dir'), call.args.indexOf('--add-dir') + 2)).toEqual(['--add-dir', library])
}
expect(calls[0].args).toContain('--resume')
expect(calls).toHaveLength(2)
})
it('别的失败不重试,照样抛出来;Claude 报的失败、没给回答也算失败', async () => {
const timeout = new ClaudeTimeoutError('超时', '')
const failing = fakeClaude([{ lines: [], error: timeout }, { lines: [result('不该到这', 's')] }])
+1 -1
View File
@@ -104,7 +104,7 @@ export interface AskRun {
/** 追问:接着 sessionId 那次会话问 prompt;会话找不到了(过期、被清理)就按完整问题重新开一次 */
followUp?: { sessionId: string, prompt: string }
cwd: string
/** cwd 以外还要读的目录(别的工作区文件夹) */
/** cwd 以外还要读的目录(别的工作区文件夹、问的工作区外的库文件所在的目录) */
addDirs?: string[]
/** 按这个 JSON Schema 交结构化结果(`--json-schema`);只要一段话回答时没有 */
schema?: string
+76 -9
View File
@@ -29,25 +29,46 @@ vi.mock('vscode', async () => {
}
})
/** 工作区只有 /workspace 一个文件夹;/workspace 下面的是项目里的文件,别处的是工作区外的库文件 */
const places = vi.hoisted(() => {
const folder = { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }
/** ⌘ 点进去的标准库源码所在的目录 */
const libraryDir = '/home/me/.local/share/uv/python/cpython-3.12/lib/python3.12'
const libraryKey = '~/.local/share/uv/python/cpython-3.12/lib/python3.12/pathlib.py'
const inWorkspace = (uri: { fsPath: string }): boolean => uri.fsPath.startsWith('/workspace/')
/** 同 locateQa:项目里的记在 /workspace、键是相对路径;库文件也记在 /workspace(第一个工作区文件夹),键是 ~/…,发给模型写绝对路径、放行它所在的目录 */
const locateQa = (uri: { fsPath: string }) => inWorkspace(uri)
? { folder, key: 'a.ts', promptPath: 'a.ts' }
: { folder, key: libraryKey, promptPath: uri.fsPath, outsideDir: libraryDir }
return { folder, libraryDir, libraryKey, inWorkspace, locateQa }
})
vi.mock('../folderStore', () => ({
/** 内存里的一份表:按键取、改了就换 */
/** 内存里的表,每个工作区文件夹一份:按键取、改了就换 */
FolderStore: class {
private map: Record<string, unknown> = {}
private readonly maps = new Map<unknown, Record<string, unknown>>()
readonly onDidChange = () => ({ dispose: () => {} })
get(_folder: unknown, key: string) { return this.map[key] }
async update(_folder: unknown, change: (map: Record<string, unknown>) => Record<string, unknown>) {
this.map = change(this.map)
get(folder: unknown, key: string) { return this.maps.get(folder)?.[key] }
async update(folder: unknown, change: (map: Record<string, unknown>) => Record<string, unknown>) {
this.maps.set(folder, change(this.maps.get(folder) ?? {}))
}
dispose() {}
},
locate: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
locate: (uri: { fsPath: string }) => places.inWorkspace(uri) ? { folder: places.folder, key: 'a.ts' } : undefined,
locateQa: places.locateQa,
}))
vi.mock('../editor/pathPreview', () => ({
pathQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
topQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'file:///workspace\na.ts' }),
pathQaLocation: places.locateQa,
topQaLocation: (uri: { fsPath: string }) => {
const at = places.locateQa(uri)
return { folder: at.folder, key: `file:///workspace\n${at.key}` }
},
}))
/** 材料只用位置给的路径,不读盘 */
vi.mock('./pathAskDescribe', () => ({
describePath: async ({ at }: { at: { promptPath: string, outsideDir?: string } }) => ({ kind: 'file', path: at.promptPath, ...(at.outsideDir && { outside: true }), head: { unavailable: '测试' } }),
}))
vi.mock('./pathAskDescribe', () => ({ describePath: async () => ({ kind: 'file', relativePath: 'a.ts', head: { unavailable: '测试' } }) }))
vi.mock('../../cc/profiles', () => ({
getDefaultProfilePath: () => '/p/offical.json',
getProfilesDir: () => '/p',
@@ -411,3 +432,49 @@ describe('顶部聊天要结构化结果的一轮(了解一块代码)', () =
expect(historyOf(qa)).toEqual([])
})
})
describe('工作区外的库文件(⌘ 点进去的标准库源码)', () => {
const library = { fsPath: `${places.libraryDir}/pathlib.py`, toString: () => `file://${places.libraryDir}/pathlib.py` } as Uri
const viewOf = (qa: CodeQa, of: Uri = library) => qa.view(of, file => file.fsPath, 'file')
/** 等这一轮问答结束的事件 */
function answered(qa: CodeQa, askId: string): Promise<Extract<AskEvent, { kind: 'end' }>> {
return new Promise((resolve) => {
qa.onDidAnswer((event) => {
if (event.kind === 'end' && event.ask.askId === askId)
resolve(event)
})
})
}
it('能问 AI,不能重写注释(会改到库文件本身);项目里的文件两样都能', () => {
const { qa } = service(context())
expect(viewOf(qa)).toMatchObject({ available: true, rewritable: false })
expect(viewOf(qa, uri)).toMatchObject({ available: true, rewritable: true })
})
it('问一块:claude 在第一个工作区文件夹里跑、另外放行读库文件所在的目录;答完记进那个文件夹的表,再看能看到这条', async () => {
const { qa, runner } = service(context())
runner.mockResolvedValueOnce({ text: '按通配符递归找。', sessionId: 's1' })
const done = answered(qa, '1')
expect(await qa.ask(library, { askId: '1', anchor: 'function rglob', question: '怎么找的', prompt: '完整的', followUp: '短的', images: [] })).toBeUndefined()
expect(runner.mock.calls[0][0]).toMatchObject({ cwd: '/workspace', addDirs: [places.libraryDir] })
expect((await done).end.status).toBe('done')
expect(viewOf(qa).threads).toEqual([{ anchor: 'function rglob', entries: [expect.objectContaining({ question: '怎么找的', answer: '按通配符递归找。' })] }])
expect(viewOf(qa, uri).threads).toEqual([])
})
it('顶部问这个文件:一样放行库文件所在的目录,发给模型的是绝对路径', async () => {
const { qa, runner } = service(context())
expect(await qa.askPath(library, 'file', { askId: '1', question: '这是什么', images: [] })).toBeUndefined()
expect(runner.mock.calls[0][0]).toMatchObject({ cwd: '/workspace', addDirs: [places.libraryDir] })
expect(runner.mock.calls[0][0].prompt).toContain(`绝对路径:${places.libraryDir}/pathlib.py`)
})
it('重写注释开不了,不启动模型', async () => {
const { qa, rewriter } = service(context())
const skill = '/ecc:writing-code-comments pathlib.py:1-3'
expect(await qa.rewrite(library, { askId: '1', anchor: 'function rglob', question: skill, prompt: skill, images: [], writeBack: async () => undefined })).toMatch(/不能重写注释/)
expect(rewriter).not.toHaveBeenCalled()
})
})
+40 -18
View File
@@ -3,7 +3,7 @@ import type { ClaudeProfile } from '../../cc/profiles'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { ChatKind, QaEntryView, QaMessage } from '../editor/messages'
import type { PathSubject, TopSubject } from '../editor/pathPreview'
import type { PathLocation } from '../folderStore'
import type { PathLocation, QaLocation } from '../folderStore'
import type { AskEnd, RunningAsk } from './asks'
import type { Answer, AskRunner, CommentAnswer, CommentAsk } from './claudeAsk'
import type { ChatEntry, QaEntry, RewriteEntry } from './qaMap'
@@ -11,7 +11,7 @@ import { randomUUID } from 'node:crypto'
import { Disposable, EventEmitter, Uri, window } from 'vscode'
import { getSettings, onDidChangeSettings } from '../../settings/store'
import { pathQaLocation, topQaLocation } from '../editor/pathPreview'
import { FolderStore, locate } from '../folderStore'
import { FolderStore, locate, locateQa } from '../folderStore'
import { AskProfiles } from './askProfiles'
import { QaAsks, REWRITE_POLICY } from './asks'
import { askClaude, askCommentBody } from './claudeAsk'
@@ -21,6 +21,12 @@ import { parsePathQa, PATH_ASK_ANCHOR, PATH_QA_STATE_KEY, pathQaEntries, without
import { isQaImageType, QA_IMAGE_DIR, removeQaImages, writeQaImages } from './qaImages'
import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry } from './qaMap'
/** 问 AI 开不了:问答记在工作区文件夹里,一个都没打开时没地方存。 */
const NO_QA_HOME = '没打开工作区文件夹,问答没地方存。'
/** 重写注释开不了:会把注释写回文件本身,工作区外的文件(库源码)不改。 */
export const NOT_REWRITABLE = '这个文件不在工作区文件夹里,不能重写注释(会改到文件本身)。'
/** 正在答的问题有了进展(update),或答完了(end;答成了带存下的那条的 id)。owner 是问的那份文件的 uri。 */
export type AskEvent
= | { owner: string, kind: 'update', ask: RunningAsk }
@@ -104,9 +110,15 @@ async function chatEntry(rootPath: string, question: string, sessionId: string,
}
}
/** claude 在 `at.folder` 里跑时还要放行读的目录(`--add-dir`):工作区外的文件所在的目录;工作区里的文件没有。 */
function outsideDirs(at: QaLocation): { addDirs?: string[] } {
return at.outsideDir === undefined ? {} : { addDirs: [at.outsideDir] }
}
/**
* 块下面的对话:问 AI 和重写注释,各自记在各工作区文件夹的一份文件里(`.spx/code-qa.json`、`.spx/comment-rewrites.json`;
* 改名、删文件、外部改动的跟随同文件注释,见 FolderStore),挂在块的位置记号上,各自接着自己最近一次的会话。
* 工作区外的库文件也能问 AI,记在第一个工作区文件夹的 `.spx/code-qa.json`(见 [locateQa]);重写注释会改文件本身,不给它们。
* 预览顶部问的是整个文件、文件夹或「了解一块代码」找的一个功能,记在这个窗口的 workspaceState 里(见 topQaLocation),不进那两份文件。
*/
export class CodeQa implements Disposable {
@@ -166,14 +178,16 @@ export class CodeQa implements Disposable {
/**
* 发给预览的问答和重写记录。块下面的按块的记号;顶部的按这条路径,subject 说是文件还是文件夹。
* 不在工作区文件夹里时没地方存,问 AI、重写注释都不可用。
* 问答记在哪见 [locateQa]:工作区外的库文件也能问,没打开工作区文件夹时问 AI 不可用([QaMessage.available])。
* 重写注释会改文件本身,只给工作区里的文件([QaMessage.rewritable])。
* 带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。重写记录的回答是交回的正文,没交回时是空的。
*/
view(uri: Uri, imageUrl: (file: Uri) => string, subject: TopSubject): QaMessage {
const at = locate(uri)
const at = locateQa(uri)
const rewriteAt = locate(uri)
const top = topQaLocation(uri)
const threads = at ? this.store.get(at.folder, at.key) ?? [] : []
const rewrites = at ? this.rewriteStore.get(at.folder, at.key) ?? [] : []
const rewrites = rewriteAt ? this.rewriteStore.get(rewriteAt.folder, rewriteAt.key) ?? [] : []
const pathEntries = top ? pathQaEntries(this.pathMap(), top.key) : []
const root = this.imageRoot(uri)
const show = (entry: QaEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.answer })
@@ -181,6 +195,7 @@ export class CodeQa implements Disposable {
return {
type: 'qa',
available: top !== undefined,
rewritable: rewriteAt !== undefined,
subject,
threads: threads.map(({ anchor, entries }) => ({ anchor, entries: entries.map(show) })),
path: pathEntries.map(show),
@@ -191,13 +206,14 @@ export class CodeQa implements Disposable {
}
/**
* 开始问;问不了时返回原因(给人看的话)。在工作区文件夹里跑 claude,它读得到整个项目。
* 开始问;问不了时返回原因(给人看的话)。在问答记着的工作区文件夹里跑 claude(见 [locateQa]),它读得到整个项目;
* 问的是工作区外的库文件时另外放行读库文件所在的目录。
* 这块问过的就接着最近一次的会话问(收起再展开、重启 VS Code 都一样),没问过就带上代码上下文开新会话。
* 用问 AI 眼下选的 profile;和那次会话开头用的不一样也照样接着问(`--resume` 和 `--settings` 能一起用)。
* 带的图片随问题发,答成了才写进 `.spx/code-qa/`、记在这条上;没答成不留文件。
*/
async ask(uri: Uri, request: AskRequest): Promise<string | undefined> {
const ready = await this.prepare(uri, request.images, 'ask')
const ready = await this.prepare(locateQa(uri), request.images, 'ask')
if (typeof ready === 'string')
return ready
const { at, profile } = ready
@@ -210,6 +226,7 @@ export class CodeQa implements Disposable {
...(sessionId && { followUp: { sessionId, prompt: followUp } }),
...(profile && { profilePath: profile.path }),
cwd: at.folder.uri.fsPath,
...outsideDirs(at),
}
return this.asks.start({ owner, askId, anchor, question, run }, {
update: ask => this.answered.fire({ owner, kind: 'update', ask }),
@@ -240,7 +257,7 @@ export class CodeQa implements Disposable {
* 模型答完先写回文件,再把这一轮记下:用户的话、交回的正文、没写进文件的原因、会话 id、profile、图片。Claude Code 报错、被叫停的不记,文件不动。
*/
async rewrite(uri: Uri, request: RewriteRequest): Promise<string | undefined> {
const ready = await this.prepare(uri, request.images, 'rewrite')
const ready = await this.prepare(locate(uri), request.images, 'rewrite')
if (typeof ready === 'string')
return ready
const { at, profile } = ready
@@ -300,11 +317,13 @@ export class CodeQa implements Disposable {
await this.rewriteStore.update(at.folder, map => withoutEntry(map, at.key, anchor, entryId))
}
/** 块下面开一轮 kind 那种对话前都要的:文件在工作区文件夹里(记录才有地方存)、图片格式认得、这种对话眼下选的 profile 还在;不行时返回原因。 */
private async prepare(uri: Uri, images: readonly ClaudeImage[], kind: ChatKind): Promise<{ at: PathLocation, profile?: ClaudeProfile } | string> {
const at = locate(uri)
/**
* 块下面开一轮 kind 那种对话前都要的:记录有地方存(`at`,问 AI 见 [locateQa],重写注释见 [locate])、图片格式认得、这种对话眼下选的 profile 还在;
* 不行时返回原因。
*/
private async prepare<L extends PathLocation>(at: L | undefined, images: readonly ClaudeImage[], kind: ChatKind): Promise<{ at: L, profile?: ClaudeProfile } | string> {
if (!at)
return `这个文件不在工作区文件夹里,${kind === 'ask' ? '问答' : '重写记录'}没地方存。`
return kind === 'ask' ? NO_QA_HOME : NOT_REWRITABLE
if (!images.every(image => isQaImageType(image.mediaType)))
return '只能带 PNG、JPEG、GIF、WebP 图片。'
try {
@@ -316,13 +335,16 @@ export class CodeQa implements Disposable {
}
}
/** 问这一整个文件或文件夹:材料是路径(文件再加开头若干行,文件夹再加这一层的名字),怎么接着会话、怎么记同 askTop。 */
/**
* 问这一整个文件或文件夹:材料是路径(文件再加开头若干行,文件夹再加这一层的名字),怎么接着会话、怎么记同 askTop。
* 工作区外的库文件同块下面的问答,放行读它所在的目录。
*/
async askPath(uri: Uri, kind: PathSubject, request: TopRequest): Promise<string | undefined> {
const at = pathQaLocation(uri)
if (!at)
return '这个路径不在工作区文件夹里,问答没地方存。'
const described = await describePath({ uri, folder: at.folder, isDirectory: kind === 'folder', relativePath: at.key })
const run = { prompt: pathAskPrompt(described, request.question), followUp: pathAskFollowUp(described.relativePath, kind, request.question) }
return NO_QA_HOME
const described = await describePath({ uri, isDirectory: kind === 'folder', at })
const run = { prompt: pathAskPrompt(described, request.question), followUp: pathAskFollowUp(described.path, kind, request.question), ...outsideDirs(at) }
return this.askTop(uri, request, run, async answer => ({ text: answer.text }))
}
@@ -334,7 +356,7 @@ export class CodeQa implements Disposable {
async askTop(subject: Uri, request: TopRequest, run: TopRun, settle: (answer: Answer) => Promise<{ text: string } | { error: string }>): Promise<string | undefined> {
const top = topQaLocation(subject)
if (!top)
return '不在工作区文件夹里,问答没地方存。'
return NO_QA_HOME
const { askId, question, images } = request
if (!images.every(image => isQaImageType(image.mediaType)))
return '只能带 PNG、JPEG、GIF、WebP 图片。'
@@ -403,7 +425,7 @@ export class CodeQa implements Disposable {
await this.deletePath(uri, entryId)
return
}
const at = locate(uri)
const at = locateQa(uri)
if (at)
await this.store.update(at.folder, map => withoutEntry(map, at.key, anchor, entryId))
}
+9 -6
View File
@@ -1,20 +1,23 @@
/**
* 顶部问答带给模型的材料:文件是相对路径加开头若干行,文件夹是相对路径加这一层的名字。
* 顶部问答带给模型的材料:文件是路径加开头若干行,文件夹是路径加这一层的名字。
* 路径见 [QaLocation.promptPath]:工作区里的是相对路径,工作区外的库文件是绝对路径。
*/
import type { Uri, WorkspaceFolder } from 'vscode'
import type { ExcludeSetting } from '../explorer/fileEntries'
import type { QaLocation } from '../folderStore'
import type { PathAskSubject, PathChild } from './pathAskPrompt'
import { FileType, workspace } from 'vscode'
import { createExcludeMatcher, sortEntries } from '../explorer/fileEntries'
import { readFileHead } from './pathAskContext'
import { displayRelativePath } from './pathQa'
export async function describePath(target: { uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, relativePath: string }): Promise<PathAskSubject> {
const relativePath = displayRelativePath(target.relativePath)
if (target.isDirectory)
return { kind: 'folder', relativePath, children: await folderChildren(target.uri, target.folder, target.relativePath) }
return { kind: 'file', relativePath, head: await readFileHead(target.uri.fsPath) }
export async function describePath(target: { uri: Uri, isDirectory: boolean, at: QaLocation }): Promise<PathAskSubject> {
const { uri, isDirectory, at } = target
const where = { path: displayRelativePath(at.promptPath), ...(at.outsideDir !== undefined && { outside: true as const }) }
if (isDirectory)
return { kind: 'folder', ...where, children: await folderChildren(uri, at.folder, at.key) }
return { kind: 'file', ...where, head: await readFileHead(uri.fsPath) }
}
/** 文件夹这一层的子文件和子目录;files.exclude 排除的不列。列不出来时只留原因。 */
+10 -3
View File
@@ -9,7 +9,7 @@ describe('问路径时发给模型的话', () => {
it('文件:写明是文件,带相对路径和开头', () => {
const prompt = pathAskPrompt({
kind: 'file',
relativePath: FILE,
path: FILE,
head: { lines: ['export default function Page() {', ' return null', '}'], truncated: false },
}, '这个名字是什么意思')
expect(prompt).toContain(`这是一个文件,相对路径:${FILE}`)
@@ -24,7 +24,7 @@ describe('问路径时发给模型的话', () => {
const lines = Array.from({ length: PATH_ASK_HEAD_LINES }, (_, index) => `line ${index}`)
const prompt = pathAskPrompt({
kind: 'file',
relativePath: FILE,
path: FILE,
head: { lines, truncated: true },
}, '?')
expect(prompt).toContain(`这是一个文件,相对路径:${FILE}`)
@@ -33,10 +33,17 @@ describe('问路径时发给模型的话', () => {
expect(prompt).not.toContain('line 80')
})
it('工作区外的库文件:写明在项目外、给绝对路径(相对路径它读不到)', () => {
const library = '/Users/me/.local/share/uv/python/cpython-3.12/lib/python3.12/pathlib.py'
const prompt = pathAskPrompt({ kind: 'file', path: library, outside: true, head: { lines: ['"""Object-oriented filesystem paths."""'], truncated: true } }, 'rglob 怎么实现的')
expect(prompt).toContain(`这是项目外的一个文件(比如依赖库的源码),绝对路径:${library}`)
expect(prompt).not.toContain('相对路径:')
})
it('文件夹:写明是文件夹,带相对路径,只列这一层的名字', () => {
const prompt = pathAskPrompt({
kind: 'folder',
relativePath: FOLDER,
path: FOLDER,
children: [
{ name: 'components', kind: 'folder' },
{ name: 'page.tsx', kind: 'file' },
+16 -13
View File
@@ -1,5 +1,5 @@
/**
* 侧栏问一个路径时发给模型的话:写明是文件还是文件夹、相对工作区的路径。
* 侧栏问一个路径时发给模型的话:写明是文件还是文件夹、相对工作区的路径(工作区外的库文件写绝对路径)。
* 文件附上开头若干行;文件夹只列这一层的名字,不把里面的代码贴进去。
*/
@@ -16,17 +16,19 @@ export type PathHead
= | { lines: string[], truncated: boolean }
| { unavailable: string }
export interface PathAskFile {
/** 问的这条路径在哪:工作区里的 [path] 是相对所在工作区文件夹的路径;[outside] 时在工作区外(比如库源码),[path] 是绝对路径 */
interface PathAskWhere {
path: string
outside?: true
}
export interface PathAskFile extends PathAskWhere {
kind: 'file'
/** 相对所在工作区文件夹的路径 */
relativePath: string
head: PathHead
}
export interface PathAskFolder {
export interface PathAskFolder extends PathAskWhere {
kind: 'folder'
/** 相对所在工作区文件夹的路径 */
relativePath: string
/** 这一层的子文件和子文件夹,调用方排好序;列不出来时是原因 */
children: PathChild[] | { unavailable: string }
}
@@ -37,17 +39,18 @@ const LEAD = '我在看项目里的一个路径,想问它。请用中文、大
/** 一次提问的全文。不写进某一块代码的问答记录。 */
export function pathAskPrompt(subject: PathAskSubject, question: string): string {
const where = subject.kind === 'file'
? `这是一个文件,相对路径:${subject.relativePath}`
: `这是一个文件夹,相对路径:${subject.relativePath}`
const noun = subject.kind === 'folder' ? '文件夹' : '文件'
const where = subject.outside
? `这是项目外的一个${noun}(比如依赖库的源码),绝对路径:${subject.path}`
: `这是一个${noun},相对路径:${subject.path}`
const detail = subject.kind === 'file' ? fileHead(subject.head) : folderList(subject.children)
return [LEAD, where, detail, `问题:${question}`].join('\n\n')
}
/** 这条路径已经问过、接着上一次会话问时发的话:只说还是这个文件或文件夹,材料在会话里。 */
export function pathAskFollowUp(relativePath: string, kind: 'file' | 'folder', question: string): string {
/** 这条路径已经问过、接着上一次会话问时发的话:只说还是这个文件或文件夹(`path` 同 [PathAskSubject] 的),材料在会话里。 */
export function pathAskFollowUp(path: string, kind: 'file' | 'folder', question: string): string {
const noun = kind === 'folder' ? '文件夹' : '文件'
return `接着问(还是 ${relativePath} 这个${noun},需要时重新读):${question}`
return `接着问(还是 ${path} 这个${noun},需要时重新读):${question}`
}
function fileHead(head: PathHead): string {
+1 -1
View File
@@ -12,7 +12,7 @@ const BLOCK_LINES = 120
const OUTER_LINES = 6
export interface AskSubject {
/** 相对工作区文件夹的路径,`/` 分隔 */
/** 这份文件写成什么(见 [发给模型的路径](vscode/src/codeReader/folderStore.ts#QaLocation.promptPath)):工作区里的是相对工作区文件夹的路径,`/` 分隔;工作区外的库文件是绝对路径 */
path: string
languageId: string
source: string
+8
View File
@@ -144,6 +144,14 @@ describe('读写 .spx/code-qa.json', () => {
await writePathMap(root, QA_FILE, {})
expect(await fsp.readFile(qaFile(), 'utf8')).toBe('{}\n')
})
it('工作区外的库文件(键是 ~/… 或绝对路径)和项目里的文件记在同一份里,读得回来', async () => {
const library = '~/.local/share/uv/python/cpython-3.12/lib/python3.12/pathlib.py'
const brew = '/opt/homebrew/lib/python3.12/site-packages/requests/api.py'
const map = withEntry(withEntry(withEntry({}, library, IF, entry('a', '2026-09-25T10:00:00.000Z')), brew, IF, entry('b', '2026-09-25T10:00:00.000Z')), 'a.py', IF, entry('c', '2026-09-25T10:00:00.000Z'))
await writePathMap(root, QA_FILE, map)
expect(await readPathMap(root, QA_FILE)).toEqual(map)
})
})
describe('问题带的图片', () => {
+24 -1
View File
@@ -208,6 +208,29 @@ describe('页面重新加载后,调用树弹框照旧开着', () => {
})
})
describe('问 AI 和重写注释各按各的能不能用', () => {
const DOC = documentOf(1, [{ ...block('0', 'rglob'), commentTarget: 'replace' }])
const qaMessage = (rewritable: boolean): ExtensionToWebview => ({ type: 'qa', available: true, rewritable, threads: [], path: [], subject: 'file', current: [], rewrites: [], rewriteCurrent: [] })
const tool = (label: string) => container.querySelector(`[data-id="0"] [aria-label="${label}"]`)
it('工作区外的库文件(rewritable 为假):块上能问 AI、顶部能问,没有重写注释的按钮', async () => {
await load()
await deliver(qaMessage(false))
await deliver(DOC)
expect(tool('问 AI')).not.toBeNull()
expect(container.querySelectorAll('.cr-qa')).toHaveLength(1)
expect(tool('按注释规范重写')).toBeNull()
})
it('项目里的文件:两个按钮都有', async () => {
await load()
await deliver(qaMessage(true))
await deliver(DOC)
expect(tool('问 AI')).not.toBeNull()
expect(tool('按注释规范重写')).not.toBeNull()
})
})
describe('代码配色跟着当前颜色主题', () => {
it('扩展端发来的配色设成 body 上的变量:词性的 --cr-<词性>,种类的 --cr-kind-<种类>;换了主题再发来就换', async () => {
const style = document.body.style
@@ -309,7 +332,7 @@ describe('一条消息只重画受影响的卡片', () => {
it('没展开、没说过的块下面不画问答区;展开一块只重画那一块', async () => {
await load()
await deliver({ type: 'qa', available: true, threads: [], path: [], subject: 'file', current: [], rewrites: [], rewriteCurrent: [] })
await deliver({ type: 'qa', available: true, rewritable: true, threads: [], path: [], subject: 'file', current: [], rewrites: [], rewriteCurrent: [] })
await deliver(copy(DOC))
// 顶部那条一直摊开
expect(container.querySelectorAll('.cr-qa')).toHaveLength(1)
@@ -25,9 +25,9 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
onToggleEdit: () => void
/** 块下面的问答区展开着 */
asking?: boolean
/** 没给就不显示「问 AI」(文件不在工作区文件夹里,问答没地方存) */
/** 没给就不显示「问 AI」(问答没地方存) */
onToggleAsk?: () => void
/** 没给就不显示「按注释规范重写」(不能写注释的块) */
/** 没给就不显示「按注释规范重写」(不能写注释的块、工作区外的库文件) */
rewrite?: RewriteTool
/** 没给就不显示调用树。count 是后台数出的棵数,大于 0 时按钮上带这个数字,是 0 时只有图标(还没数到、没有调用者,点开时会当场再问) */
callTree?: { anchor: string, count: number, open: boolean, onOpen: () => void }
@@ -82,9 +82,9 @@ export interface BlockTreeHandle {
edit?: { onToggleEdit: (id: string) => void, onFocused: () => void }
/** 打开一块的调用树弹框。和 state.callTree 同时有 */
onOpenCall?: (anchor: string) => void
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时 state 里 available 是 false */
/** 问 AI;问答没地方存(没打开工作区文件夹)时 `state` 里 [ChatState.available] 是 `false` */
qa?: QaTree
/** 按注释规范重写;同问 AI */
/** 按注释规范重写;工作区外的库文件不能重写,`state` 里 [ChatState.available] 是 `false` */
rewrite?: RewriteTree
}
@@ -247,7 +247,7 @@ function shapeOf(block: BlockView, state: BlockTreeState): BlockShape {
}
}
/** 一块的问 AI:能问(文件在工作区文件夹里)时按钮展开没有;对话区展开着或说过时才有,没展开又没说过的块不画。 */
/** 一块的问 AI:能问(问答有地方存)时按钮展开没有;对话区展开着或说过时才有,没展开又没说过的块不画。 */
interface AskShape {
asking?: boolean
chat?: ChatView
+2 -1
View File
@@ -10,7 +10,7 @@ function thread(anchor: string, ...ids: string[]): QaThreadView {
}
function message(threads: QaThreadView[], current: QaMessage['current'] = []): QaMessage {
return { type: 'qa', available: true, threads, path: [], subject: 'file', current, rewrites: [], rewriteCurrent: [] }
return { type: 'qa', available: true, rewritable: true, threads, path: [], subject: 'file', current, rewrites: [], rewriteCurrent: [] }
}
function qa(threads: QaThreadView[], current: QaMessage['current'] = []): QaAction {
@@ -53,6 +53,7 @@ describe('问 AI 的状态', () => {
message: {
type: 'qa',
available: true,
rewritable: true,
threads: [],
path: [{ id: 'p', question: '这文件夹干什么', answer: '放源码', createdAt: '2026-09-25T10:00:00.000Z' }],
subject: 'folder',
+1 -1
View File
@@ -32,7 +32,7 @@ export const PATH_ASK_ANCHOR = '\0path'
/** 一种块下面的对话的状态 */
export interface ChatState {
/** 文件不在工作区文件夹里时记录没地方存,不给这种对话 */
/** 这份文件能不能开这种对话(见 [QaMessage.available]、[QaMessage.rewritable]),不能时不给 */
available: boolean
threads: QaThreadView[]
/** 当前这一轮的,按先后 */
+3 -1
View File
@@ -334,12 +334,14 @@ export interface AskRound {
}
/**
* 这份文件的问答和重写记录:available 为假时文件不在工作区文件夹里、没地方存,不给「问 AI」和「重写注释」;
* 这份文件的问答和重写记录:[available] 为假时问答没地方存(没打开工作区文件夹),不给「问 AI」;
* [rewritable] 为假时不给「重写注释」(工作区外的库文件,重写会改到它本身)。
* current、rewriteCurrent 是扩展端记着的当前一轮(页面重新加载后靠它们接上)
*/
export interface QaMessage {
type: 'qa'
available: boolean
rewritable: boolean
threads: QaThreadView[]
/** 这一页顶部、按路径记的问答;不挂在块上 */
path: QaEntryView[]
@@ -55,7 +55,7 @@ export function useRewrite(): RewriteTree {
const off = onMessage((msg) => {
switch (msg.type) {
case 'qa':
dispatch({ type: 'sync', available: msg.available, threads: msg.rewrites, current: msg.rewriteCurrent })
dispatch({ type: 'sync', available: msg.rewritable, threads: msg.rewrites, current: msg.rewriteCurrent })
return
case 'profiles':
if (msg.kind === 'rewrite')