✨ feat(vscode): 代码阅读预览里工作区外的库文件也能问 AI,问答记进第一个工作区文件夹,重写注释不对库文件开放
This commit is contained in:
1 parent
cecae0e244
commit
39dfd9b084
24 files changed
+463
-79
No files matched your search
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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> {
|
||||
|
||||
@@ -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': '营销活动',
|
||||
|
||||
@@ -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')] }])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 排除的不列。列不出来时只留原因。 */
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('问题带的图片', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -32,7 +32,7 @@ export const PATH_ASK_ANCHOR = '\0path'
|
||||
|
||||
/** 一种块下面的对话的状态 */
|
||||
export interface ChatState {
|
||||
/** 文件不在工作区文件夹里时记录没地方存,不给这种对话 */
|
||||
/** 这份文件能不能开这种对话(见 [QaMessage.available]、[QaMessage.rewritable]),不能时不给 */
|
||||
available: boolean
|
||||
threads: QaThreadView[]
|
||||
/** 当前这一轮的,按先后 */
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user