✨ feat(vscode): 侧栏的文件和文件夹可以问 AI
This commit is contained in:
1 parent
fc403d5b20
commit
14450c90bc
6 files changed
+627
-6
No files matched your search
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* 侧栏文件行、文件夹行的「问 AI」:单独一个面板,不属于任何一个文件的预览。
|
||||
* 先问问题,再在同一面板里把回答流出来;不写进某一块代码的问答记录。
|
||||
*/
|
||||
|
||||
import type { WebviewPanel } from 'vscode'
|
||||
import type { AskProfiles } from '../qa/askProfiles'
|
||||
import type { PathAskSubject, PathChild } from '../qa/pathAskPrompt'
|
||||
import type { ExcludeSetting } from './fileEntries'
|
||||
import type { FileNode } from './fileTree'
|
||||
import * as path from 'node:path'
|
||||
import { commands, Disposable, FileType, ViewColumn, window, workspace } from 'vscode'
|
||||
import { ClaudeCancelledError } from '../../cc/spawnClaude'
|
||||
import { makeNonce } from '../../webview/html'
|
||||
import { askClaude, askFailure } from '../qa/claudeAsk'
|
||||
import { readFileHead } from '../qa/pathAskContext'
|
||||
import { pathAskPrompt } from '../qa/pathAskPrompt'
|
||||
import { createExcludeMatcher, sortEntries } from './fileEntries'
|
||||
|
||||
export const ASK_PATH_COMMAND = 'superpowers.codeReader.askPath'
|
||||
|
||||
const VIEW_TYPE = 'superpowers.codeReader.pathAsk'
|
||||
|
||||
type PathTarget = Pick<FileNode, 'uri' | 'folder' | 'isDirectory'> & { relativePath: string }
|
||||
|
||||
interface ResetMessage { type: 'reset', path: string, kind: 'file' | 'folder' }
|
||||
interface AskedMessage { type: 'asked', question: string }
|
||||
interface StreamMessage { type: 'stream', text?: string, activity?: string }
|
||||
interface EndMessage { type: 'end', status: 'done' | 'failed' | 'stopped', error?: string }
|
||||
type ToPanel = ResetMessage | AskedMessage | StreamMessage | EndMessage
|
||||
|
||||
/** 打开或切到问路径的面板。点侧栏另一行时换到那一行,正在答的停掉。 */
|
||||
export function registerPathAsk(profiles: AskProfiles): Disposable {
|
||||
const panel = new PathAskPanel(profiles)
|
||||
return Disposable.from(
|
||||
commands.registerCommand(ASK_PATH_COMMAND, (node?: FileNode) => {
|
||||
void panel.open(node)
|
||||
}),
|
||||
panel,
|
||||
)
|
||||
}
|
||||
|
||||
class PathAskPanel implements Disposable {
|
||||
private panel: WebviewPanel | undefined
|
||||
private whenReady = Promise.resolve()
|
||||
private target: PathTarget | undefined
|
||||
/** 换了路径就加一,旧的回答不再往面板上画 */
|
||||
private generation = 0
|
||||
private controller: AbortController | undefined
|
||||
|
||||
constructor(private readonly profiles: AskProfiles) {}
|
||||
|
||||
async open(node?: FileNode): Promise<void> {
|
||||
if (!node || node.isRoot)
|
||||
return
|
||||
const gen = ++this.generation
|
||||
this.controller?.abort()
|
||||
this.controller = undefined
|
||||
const relativePath = path.posix.relative(node.folder.uri.path, node.uri.path)
|
||||
this.target = { uri: node.uri, folder: node.folder, isDirectory: node.isDirectory, relativePath }
|
||||
const title = `问 AI · ${relativePath}`
|
||||
if (!this.panel) {
|
||||
this.create(title)
|
||||
}
|
||||
else {
|
||||
this.panel.title = title
|
||||
this.panel.reveal(ViewColumn.Active, false)
|
||||
}
|
||||
await this.whenReady
|
||||
if (gen !== this.generation || !this.panel)
|
||||
return
|
||||
this.post({ type: 'reset', path: relativePath, kind: node.isDirectory ? 'folder' : 'file' })
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.generation++
|
||||
this.controller?.abort()
|
||||
this.controller = undefined
|
||||
this.panel?.dispose()
|
||||
this.panel = undefined
|
||||
}
|
||||
|
||||
private create(title: string): void {
|
||||
const panel = window.createWebviewPanel(VIEW_TYPE, title, { viewColumn: ViewColumn.Active, preserveFocus: false }, {
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
})
|
||||
this.panel = panel
|
||||
let markReady: () => void = () => {}
|
||||
this.whenReady = new Promise((resolve) => {
|
||||
markReady = resolve
|
||||
})
|
||||
const nonce = makeNonce()
|
||||
panel.webview.html = pathAskHtml(nonce)
|
||||
panel.webview.onDidReceiveMessage((msg: unknown) => {
|
||||
if (!isRecord(msg) || typeof msg.type !== 'string')
|
||||
return
|
||||
if (msg.type === 'ready')
|
||||
markReady()
|
||||
else if (msg.type === 'ask' && typeof msg.question === 'string')
|
||||
void this.submit(msg.question)
|
||||
else if (msg.type === 'stop')
|
||||
this.controller?.abort()
|
||||
})
|
||||
panel.onDidDispose(() => {
|
||||
if (this.panel !== panel)
|
||||
return
|
||||
this.panel = undefined
|
||||
this.generation++
|
||||
this.controller?.abort()
|
||||
this.controller = undefined
|
||||
})
|
||||
}
|
||||
|
||||
/** 空问题不发。回答过程里的字和「正在读哪个文件」直接画在这个面板上。 */
|
||||
private async submit(question: string): Promise<void> {
|
||||
const text = question.trim()
|
||||
const target = this.target
|
||||
if (!text || !target || this.controller)
|
||||
return
|
||||
const gen = this.generation
|
||||
const controller = new AbortController()
|
||||
this.controller = controller
|
||||
this.post({ type: 'asked', question: text })
|
||||
try {
|
||||
const described = await describePath(target)
|
||||
if (gen !== this.generation)
|
||||
return
|
||||
const profile = await this.profiles.current()
|
||||
if (gen !== this.generation)
|
||||
return
|
||||
const prompt = pathAskPrompt(described, text)
|
||||
await askClaude({
|
||||
prompt,
|
||||
images: [],
|
||||
...(profile && { profilePath: profile.path }),
|
||||
cwd: target.folder.uri.fsPath,
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => {
|
||||
if (gen !== this.generation)
|
||||
return
|
||||
if (event.kind === 'text')
|
||||
this.post({ type: 'stream', text: event.text })
|
||||
else if (event.kind === 'activity')
|
||||
this.post({ type: 'stream', activity: event.label })
|
||||
},
|
||||
})
|
||||
if (gen !== this.generation)
|
||||
return
|
||||
this.post({ type: 'end', status: 'done' })
|
||||
}
|
||||
catch (err) {
|
||||
if (gen !== this.generation)
|
||||
return
|
||||
if (err instanceof ClaudeCancelledError)
|
||||
this.post({ type: 'end', status: 'stopped' })
|
||||
else
|
||||
this.post({ type: 'end', status: 'failed', error: askFailure(err) })
|
||||
}
|
||||
finally {
|
||||
if (this.controller === controller)
|
||||
this.controller = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private post(message: ToPanel): void {
|
||||
void this.panel?.webview.postMessage(message).then(undefined, () => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** 文件带开头,文件夹带这一层(和侧栏同一套排除、同一排序)。 */
|
||||
async function describePath(target: PathTarget): Promise<PathAskSubject> {
|
||||
if (target.isDirectory)
|
||||
return { kind: 'folder', relativePath: target.relativePath, children: await folderChildren(target) }
|
||||
return { kind: 'file', relativePath: target.relativePath, head: await readFileHead(target.uri.fsPath) }
|
||||
}
|
||||
|
||||
async function folderChildren(target: PathTarget): Promise<PathChild[] | { unavailable: string }> {
|
||||
let entries: [string, FileType][]
|
||||
try {
|
||||
entries = await workspace.fs.readDirectory(target.uri)
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return { unavailable: `列不出来(${message})。` }
|
||||
}
|
||||
const excluded = createExcludeMatcher(workspace.getConfiguration('files', target.folder).get<ExcludeSetting>('exclude'))
|
||||
const names = new Set(entries.map(([name]) => name))
|
||||
const dir = target.relativePath
|
||||
return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 })))
|
||||
.filter(entry => !excluded(dir ? `${dir}/${entry.name}` : entry.name, names))
|
||||
.map(entry => ({ name: entry.name, kind: entry.isDirectory ? 'folder' : 'file' }))
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** 面板里的一页:暗色遮罩铺满,中间一张卡片。问、流式回答、停止都在这张卡片上。 */
|
||||
function pathAskHtml(nonce: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>问 AI</title>
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: var(--vscode-foreground);
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(40rem, calc(100% - 2rem));
|
||||
max-height: calc(100% - 2rem);
|
||||
background: var(--vscode-editor-background);
|
||||
color: var(--vscode-editor-foreground);
|
||||
border: 1px solid var(--vscode-panel-border, var(--vscode-widget-border, transparent));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
header { padding: 14px 16px 0; }
|
||||
.kind { opacity: 0.7; font-size: 12px; }
|
||||
h1 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
.thread { overflow: auto; padding: 12px 16px; min-height: 0; }
|
||||
.question { margin: 0 0 8px; font-weight: 600; white-space: pre-wrap; word-break: break-word; }
|
||||
.answer {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--vscode-editor-font-family, var(--vscode-font-family));
|
||||
}
|
||||
.status { margin: 8px 0 0; opacity: 0.75; }
|
||||
.error { margin: 8px 0 0; color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
||||
form { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px 16px; }
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 4.5rem;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
font: inherit;
|
||||
}
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
button.primary { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
|
||||
button.primary:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button.secondary { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); }
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" role="dialog" aria-labelledby="path">
|
||||
<header>
|
||||
<div class="kind" id="kind"></div>
|
||||
<h1 id="path"></h1>
|
||||
</header>
|
||||
<div class="thread" id="thread" hidden>
|
||||
<p class="question" id="q"></p>
|
||||
<pre class="answer" id="answer" hidden></pre>
|
||||
<p class="status" id="status" hidden></p>
|
||||
<p class="error" id="error" hidden></p>
|
||||
</div>
|
||||
<form id="form">
|
||||
<textarea id="question" rows="3" placeholder="想问什么,比如这个名字是什么意思"></textarea>
|
||||
<div class="actions">
|
||||
<button type="button" class="secondary" id="stop" hidden>停止</button>
|
||||
<button type="submit" class="primary" id="send" disabled>发送</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi()
|
||||
const pathEl = document.getElementById('path')
|
||||
const kindEl = document.getElementById('kind')
|
||||
const thread = document.getElementById('thread')
|
||||
const questionEl = document.getElementById('q')
|
||||
const answerEl = document.getElementById('answer')
|
||||
const statusEl = document.getElementById('status')
|
||||
const errorEl = document.getElementById('error')
|
||||
const form = document.getElementById('form')
|
||||
const input = document.getElementById('question')
|
||||
const sendBtn = document.getElementById('send')
|
||||
const stopBtn = document.getElementById('stop')
|
||||
let running = false
|
||||
let text = ''
|
||||
let activity = ''
|
||||
let frame
|
||||
|
||||
const paint = () => {
|
||||
frame = undefined
|
||||
answerEl.textContent = text
|
||||
answerEl.hidden = text === ''
|
||||
if (!running)
|
||||
return
|
||||
statusEl.hidden = false
|
||||
statusEl.textContent = activity || (text === '' ? '正在想…' : '正在回答…')
|
||||
}
|
||||
|
||||
const setRunning = (on) => {
|
||||
running = on
|
||||
input.disabled = on
|
||||
stopBtn.hidden = !on
|
||||
if (!on)
|
||||
statusEl.hidden = true
|
||||
sendBtn.disabled = on || input.value.trim() === ''
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data
|
||||
if (!msg || typeof msg.type !== 'string')
|
||||
return
|
||||
if (msg.type === 'reset') {
|
||||
clearTimeout(frame)
|
||||
text = ''
|
||||
activity = ''
|
||||
pathEl.textContent = typeof msg.path === 'string' ? msg.path : ''
|
||||
kindEl.textContent = msg.kind === 'folder' ? '文件夹' : '文件'
|
||||
questionEl.textContent = ''
|
||||
answerEl.textContent = ''
|
||||
answerEl.hidden = true
|
||||
errorEl.hidden = true
|
||||
errorEl.textContent = ''
|
||||
thread.hidden = true
|
||||
input.value = ''
|
||||
setRunning(false)
|
||||
input.focus()
|
||||
return
|
||||
}
|
||||
if (msg.type === 'asked') {
|
||||
thread.hidden = false
|
||||
questionEl.textContent = typeof msg.question === 'string' ? msg.question : ''
|
||||
text = ''
|
||||
activity = ''
|
||||
answerEl.textContent = ''
|
||||
answerEl.hidden = true
|
||||
errorEl.hidden = true
|
||||
errorEl.textContent = ''
|
||||
setRunning(true)
|
||||
paint()
|
||||
return
|
||||
}
|
||||
if (msg.type === 'stream') {
|
||||
if (typeof msg.text === 'string') {
|
||||
text = msg.text
|
||||
activity = ''
|
||||
}
|
||||
if (typeof msg.activity === 'string')
|
||||
activity = msg.activity
|
||||
frame ??= setTimeout(paint, 80)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'end') {
|
||||
clearTimeout(frame)
|
||||
paint()
|
||||
setRunning(false)
|
||||
if (msg.status === 'failed') {
|
||||
errorEl.hidden = false
|
||||
errorEl.textContent = typeof msg.error === 'string' && msg.error ? msg.error : '没答成。'
|
||||
}
|
||||
else if (msg.status === 'stopped') {
|
||||
errorEl.hidden = false
|
||||
errorEl.textContent = '已停止。'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const send = () => {
|
||||
const question = input.value.trim()
|
||||
if (!question || running)
|
||||
return
|
||||
vscode.postMessage({ type: 'ask', question })
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
send()
|
||||
})
|
||||
input.addEventListener('input', () => {
|
||||
sendBtn.disabled = running || input.value.trim() === ''
|
||||
})
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
send()
|
||||
}
|
||||
})
|
||||
stopBtn.addEventListener('click', () => vscode.postMessage({ type: 'stop' }))
|
||||
vscode.postMessage({ type: 'ready' })
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const root = entryContext({ isDirectory: true, isRoot: true })
|
||||
/** 「未提交的更改」里「更改」组的文件行 */
|
||||
const unstaged = entryContext({ isDirectory: false, isRoot: false }, UNSTAGED_MARKER)
|
||||
const STAGE = 'superpowers.codeReader.stage'
|
||||
const ASK = 'superpowers.codeReader.askPath'
|
||||
const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue
|
||||
|
||||
const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0)
|
||||
@@ -56,17 +57,17 @@ const FOLDER_COPIES = [
|
||||
]
|
||||
|
||||
describe('侧栏行尾按钮', () => {
|
||||
it('先「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;工作区根都没有', () => {
|
||||
it('先「问 AI」再「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;工作区根都没有', () => {
|
||||
for (const context of [file, folder]) {
|
||||
expect(itemsFor('view/item/context', 'inline', context)).toEqual([COPY_MENU, ADD_NOTE])
|
||||
expect(itemsFor('view/item/context', 'inline', noted(context))).toEqual([COPY_MENU, EDIT_NOTE])
|
||||
expect(itemsFor('view/item/context', 'inline', context)).toEqual([ASK, COPY_MENU, ADD_NOTE])
|
||||
expect(itemsFor('view/item/context', 'inline', noted(context))).toEqual([ASK, COPY_MENU, EDIT_NOTE])
|
||||
}
|
||||
expect(itemsFor('view/item/context', 'inline', root)).toEqual([])
|
||||
})
|
||||
|
||||
it('「更改」组的文件行多一个「暂存」,排在最后;别的行都没有', () => {
|
||||
expect(itemsFor('view/item/context', 'inline', unstaged)).toEqual([COPY_MENU, ADD_NOTE, STAGE])
|
||||
expect(itemsFor('view/item/context', 'inline', noted(unstaged))).toEqual([COPY_MENU, EDIT_NOTE, STAGE])
|
||||
expect(itemsFor('view/item/context', 'inline', unstaged)).toEqual([ASK, COPY_MENU, ADD_NOTE, STAGE])
|
||||
expect(itemsFor('view/item/context', 'inline', noted(unstaged))).toEqual([ASK, COPY_MENU, EDIT_NOTE, STAGE])
|
||||
for (const context of [file, folder, root, noted(file), noted(folder)])
|
||||
expect(itemsFor('view/item/context', 'inline', context)).not.toContain(STAGE)
|
||||
})
|
||||
@@ -82,6 +83,21 @@ describe('侧栏行尾按钮', () => {
|
||||
expect(commands.find(item => item.command === ADD_NOTE)?.icon).toBe('$(note)')
|
||||
expect(commands.find(item => item.command === EDIT_NOTE)?.icon).toBe('$(edit)')
|
||||
})
|
||||
|
||||
it('「问 AI」在复制左边,文件和文件夹(含未提交、有注释)都有,审查时也不藏,命令面板里没有', () => {
|
||||
const ask = menus['view/item/context'].filter(item => item.command === ASK)
|
||||
expect(ask).toEqual([{
|
||||
command: ASK,
|
||||
when: 'view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/',
|
||||
group: 'inline@0',
|
||||
}])
|
||||
expect(ask[0].when.includes('filter')).toBe(false)
|
||||
for (const context of [file, folder, unstaged, noted(file), noted(folder), noted(unstaged)])
|
||||
expect(itemsFor('view/item/context', 'inline', context)[0]).toBe(ASK)
|
||||
expect(itemsFor('view/item/context', 'inline', root)).not.toContain(ASK)
|
||||
expect(commands.find(item => item.command === ASK)).toMatchObject({ title: '问 AI', icon: '$(sparkle)' })
|
||||
expect(menus.commandPalette.find(item => item.command === ASK)?.when).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('复制菜单', () => {
|
||||
|
||||
Reference in new issue
Block a user