From 39dfd9b08490a26625b845958edd3c178c6002d6 Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 13 Oct 2026 21:50:54 +1100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E9=A2=84=E8=A7=88=E9=87=8C=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E5=A4=96=E7=9A=84=E5=BA=93=E6=96=87=E4=BB=B6=E4=B9=9F?= =?UTF-8?q?=E8=83=BD=E9=97=AE=20AI=EF=BC=8C=E9=97=AE=E7=AD=94=E8=AE=B0?= =?UTF-8?q?=E8=BF=9B=E7=AC=AC=E4=B8=80=E4=B8=AA=E5=B7=A5=E4=BD=9C=E5=8C=BA?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9=EF=BC=8C=E9=87=8D=E5=86=99=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E4=B8=8D=E5=AF=B9=E5=BA=93=E6=96=87=E4=BB=B6=E5=BC=80?= =?UTF-8?q?=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/editor/messages.ts | 4 +- .../src/codeReader/editor/pathPreview.test.ts | 35 ++++++++ vscode/src/codeReader/editor/pathPreview.ts | 18 ++-- vscode/src/codeReader/editor/previewEditor.ts | 9 +- vscode/src/codeReader/folderStore.test.ts | 88 +++++++++++++++++++ vscode/src/codeReader/folderStore.ts | 41 ++++++++- vscode/src/codeReader/pathMap.test.ts | 37 +++++++- vscode/src/codeReader/pathMap.ts | 37 +++++++- vscode/src/codeReader/qa/claudeAsk.test.ts | 15 ++++ vscode/src/codeReader/qa/claudeAsk.ts | 2 +- vscode/src/codeReader/qa/codeQa.test.ts | 85 ++++++++++++++++-- vscode/src/codeReader/qa/codeQa.ts | 58 ++++++++---- vscode/src/codeReader/qa/pathAskDescribe.ts | 15 ++-- .../src/codeReader/qa/pathAskPrompt.test.ts | 13 ++- vscode/src/codeReader/qa/pathAskPrompt.ts | 29 +++--- vscode/src/codeReader/qa/prompt.ts | 2 +- vscode/src/codeReader/qa/qaMap.test.ts | 8 ++ vscode/webview-ui/src/codeReader/App.test.tsx | 25 +++++- .../webview-ui/src/codeReader/BlockTools.tsx | 4 +- .../webview-ui/src/codeReader/BlockTree.tsx | 6 +- vscode/webview-ui/src/codeReader/qa.test.ts | 3 +- vscode/webview-ui/src/codeReader/qa.ts | 2 +- vscode/webview-ui/src/codeReader/types.ts | 4 +- .../webview-ui/src/codeReader/useRewrite.ts | 2 +- 24 files changed, 463 insertions(+), 79 deletions(-) create mode 100644 vscode/src/codeReader/editor/pathPreview.test.ts create mode 100644 vscode/src/codeReader/folderStore.test.ts diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index a7f88fe..35bf8d8 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -150,8 +150,10 @@ export interface QaThreadView { */ export interface QaMessage { type: 'qa' - /** 不在工作区文件夹里时没地方存,问 AI、重写注释不可用 */ + /** 能问 AI:问答有地方存(工作区外的库文件记在第一个工作区文件夹);没打开工作区文件夹时不能 */ available: boolean + /** 能重写注释:只有工作区里的文件,工作区外的库文件不改 */ + rewritable: boolean /** 块下面的问答,按块的记号 */ threads: QaThreadView[] /** 这一页顶部、按路径记的问答;不挂在块上 */ diff --git a/vscode/src/codeReader/editor/pathPreview.test.ts b/vscode/src/codeReader/editor/pathPreview.test.ts new file mode 100644 index 0000000..716cac3 --- /dev/null +++ b/vscode/src/codeReader/editor/pathPreview.test.ts @@ -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' }) + }) +}) diff --git a/vscode/src/codeReader/editor/pathPreview.ts b/vscode/src/codeReader/editor/pathPreview.ts index e880a96..566952c 100644 --- a/vscode/src/codeReader/editor/pathPreview.ts +++ b/vscode/src/codeReader/editor/pathPreview.ts @@ -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) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index dc44f08..e6146d7 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -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) { diff --git a/vscode/src/codeReader/folderStore.test.ts b/vscode/src/codeReader/folderStore.test.ts new file mode 100644 index 0000000..1d1e090 --- /dev/null +++ b/vscode/src/codeReader/folderStore.test.ts @@ -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') + }) +}) diff --git a/vscode/src/codeReader/folderStore.ts b/vscode/src/codeReader/folderStore.ts index 5c8b947..82e215d 100644 --- a/vscode/src/codeReader/folderStore.ts +++ b/vscode/src/codeReader/folderStore.ts @@ -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 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): Promise { diff --git a/vscode/src/codeReader/pathMap.test.ts b/vscode/src/codeReader/pathMap.test.ts index fa29a20..1c57a09 100644 --- a/vscode/src/codeReader/pathMap.test.ts +++ b/vscode/src/codeReader/pathMap.test.ts @@ -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': '营销活动', diff --git a/vscode/src/codeReader/pathMap.ts b/vscode/src/codeReader/pathMap.ts index cfbbc98..7b5def7 100644 --- a/vscode/src/codeReader/pathMap.ts +++ b/vscode/src/codeReader/pathMap.ts @@ -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(workspaceRoot: string, file: PathMapFile): string { return path.join(workspaceRoot, ...file.path.split('/')) } -/** 读工作区文件夹里的这份表。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键不合法、值认不出的条目跳过。 */ +/** 读工作区文件夹里的这份表。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键认不出(见 [tableKey])、值认不出的条目跳过。 */ export async function readPathMap(workspaceRoot: string, file: PathMapFile): Promise> { let raw: string try { @@ -83,7 +116,7 @@ export async function readPathMap(workspaceRoot: string, file: PathMapFile return {} const map: PathMap = {} for (const [rawKey, rawValue] of Object.entries(parsed as Record)) { - const key = normalizeKey(rawKey) + const key = tableKey(rawKey) const value = file.parse(rawValue) if (key !== undefined && value !== undefined) map[key] = value diff --git a/vscode/src/codeReader/qa/claudeAsk.test.ts b/vscode/src/codeReader/qa/claudeAsk.test.ts index 71b0620..f89f8de 100644 --- a/vscode/src/codeReader/qa/claudeAsk.test.ts +++ b/vscode/src/codeReader/qa/claudeAsk.test.ts @@ -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')] }]) diff --git a/vscode/src/codeReader/qa/claudeAsk.ts b/vscode/src/codeReader/qa/claudeAsk.ts index 4ab279e..4d4378f 100644 --- a/vscode/src/codeReader/qa/claudeAsk.ts +++ b/vscode/src/codeReader/qa/claudeAsk.ts @@ -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 diff --git a/vscode/src/codeReader/qa/codeQa.test.ts b/vscode/src/codeReader/qa/codeQa.test.ts index 62c1f60..a6f2fce 100644 --- a/vscode/src/codeReader/qa/codeQa.test.ts +++ b/vscode/src/codeReader/qa/codeQa.test.ts @@ -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 = {} + private readonly maps = new Map>() readonly onDidChange = () => ({ dispose: () => {} }) - get(_folder: unknown, key: string) { return this.map[key] } - async update(_folder: unknown, change: (map: Record) => Record) { - this.map = change(this.map) + get(folder: unknown, key: string) { return this.maps.get(folder)?.[key] } + async update(folder: unknown, change: (map: Record) => Record) { + 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> { + 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() + }) +}) diff --git a/vscode/src/codeReader/qa/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts index 7e56cd7..1fabab3 100644 --- a/vscode/src/codeReader/qa/codeQa.ts +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -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 { - 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 { - 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(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 { 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 { 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)) } diff --git a/vscode/src/codeReader/qa/pathAskDescribe.ts b/vscode/src/codeReader/qa/pathAskDescribe.ts index 9195fe6..22b963a 100644 --- a/vscode/src/codeReader/qa/pathAskDescribe.ts +++ b/vscode/src/codeReader/qa/pathAskDescribe.ts @@ -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 { - 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 { + 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 排除的不列。列不出来时只留原因。 */ diff --git a/vscode/src/codeReader/qa/pathAskPrompt.test.ts b/vscode/src/codeReader/qa/pathAskPrompt.test.ts index cd3a52e..764de8a 100644 --- a/vscode/src/codeReader/qa/pathAskPrompt.test.ts +++ b/vscode/src/codeReader/qa/pathAskPrompt.test.ts @@ -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' }, diff --git a/vscode/src/codeReader/qa/pathAskPrompt.ts b/vscode/src/codeReader/qa/pathAskPrompt.ts index 1085004..c67c6cf 100644 --- a/vscode/src/codeReader/qa/pathAskPrompt.ts +++ b/vscode/src/codeReader/qa/pathAskPrompt.ts @@ -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 { diff --git a/vscode/src/codeReader/qa/prompt.ts b/vscode/src/codeReader/qa/prompt.ts index 753d890..a6a5cf6 100644 --- a/vscode/src/codeReader/qa/prompt.ts +++ b/vscode/src/codeReader/qa/prompt.ts @@ -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 diff --git a/vscode/src/codeReader/qa/qaMap.test.ts b/vscode/src/codeReader/qa/qaMap.test.ts index d023e73..a22b861 100644 --- a/vscode/src/codeReader/qa/qaMap.test.ts +++ b/vscode/src/codeReader/qa/qaMap.test.ts @@ -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('问题带的图片', () => { diff --git a/vscode/webview-ui/src/codeReader/App.test.tsx b/vscode/webview-ui/src/codeReader/App.test.tsx index cdd3db7..79001a8 100644 --- a/vscode/webview-ui/src/codeReader/App.test.tsx +++ b/vscode/webview-ui/src/codeReader/App.test.tsx @@ -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) diff --git a/vscode/webview-ui/src/codeReader/BlockTools.tsx b/vscode/webview-ui/src/codeReader/BlockTools.tsx index 8c66b7c..564f716 100644 --- a/vscode/webview-ui/src/codeReader/BlockTools.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTools.tsx @@ -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 } diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 5d318fe..ce88d96 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -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 diff --git a/vscode/webview-ui/src/codeReader/qa.test.ts b/vscode/webview-ui/src/codeReader/qa.test.ts index efbaf50..4802d1a 100644 --- a/vscode/webview-ui/src/codeReader/qa.test.ts +++ b/vscode/webview-ui/src/codeReader/qa.test.ts @@ -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', diff --git a/vscode/webview-ui/src/codeReader/qa.ts b/vscode/webview-ui/src/codeReader/qa.ts index fe0bfdf..6566b24 100644 --- a/vscode/webview-ui/src/codeReader/qa.ts +++ b/vscode/webview-ui/src/codeReader/qa.ts @@ -32,7 +32,7 @@ export const PATH_ASK_ANCHOR = '\0path' /** 一种块下面的对话的状态 */ export interface ChatState { - /** 文件不在工作区文件夹里时记录没地方存,不给这种对话 */ + /** 这份文件能不能开这种对话(见 [QaMessage.available]、[QaMessage.rewritable]),不能时不给 */ available: boolean threads: QaThreadView[] /** 当前这一轮的,按先后 */ diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 9709393..f916908 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -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[] diff --git a/vscode/webview-ui/src/codeReader/useRewrite.ts b/vscode/webview-ui/src/codeReader/useRewrite.ts index 4ca2b18..4451eff 100644 --- a/vscode/webview-ui/src/codeReader/useRewrite.ts +++ b/vscode/webview-ui/src/codeReader/useRewrite.ts @@ -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')