✨ feat(vscode): 文件和文件夹在预览顶部问

侧栏「问 AI」打开代码阅读预览。文件夹是没有代码块的一页,源码文件在整页顶部聊,代码块下面的问答不动。历史按工作区文件夹和相对路径记在窗口状态里,不写进按块存的记录。
This commit is contained in:
cruldra committed 2026-10-03 03:22:20 +08:00
1 parent 2be635d1e5
commit 2c10acf9c4
19 files changed
+610 -459

No files matched your search

+27 -1
View File
@@ -6,6 +6,7 @@ import type { OccurrencePage } from './useOccurrences'
import type { ViewState } from './viewState'
import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AskThread } from './AskThread'
import { BlockLevel } from './BlockTree'
import { DiffBar, NewFileBanner } from './DiffBar'
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
@@ -14,6 +15,7 @@ import { coveredByEditing, startEditing } from './editing'
import { applyFilter, excludedBy, keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar'
import { OverviewRuler } from './OverviewRuler'
import { asksOf, historyOf, PATH_ASK_ANCHOR } from './qa'
import { useChangeNav } from './useChangeNav'
import { useDefinitionLinks } from './useDefinitionLinks'
import { HighlightContext, useOccurrences } from './useOccurrences'
@@ -39,6 +41,8 @@ function savedBlockModes(): BlockModes {
export function App(): ReactElement | null {
// 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答
const qa = useQa()
/** 侧栏「问 AI」要聚焦顶部输入框时加一 */
const [pathFocus, setPathFocus] = useState(0)
/** 按注释规范重写的过程,只在这一页上 */
const rewrites = useRewrite()
const [doc, setDoc] = useState<DocumentMessage | null>(null)
@@ -121,6 +125,9 @@ export function App(): ReactElement | null {
return
case 'diff':
setDiff(msg.diff)
return
case 'focusPathAsk':
setPathFocus(n => n + 1)
}
})
postMessage({ type: 'ready' })
@@ -191,6 +198,25 @@ export function App(): ReactElement | null {
/>
{changes && diff && <OverviewRuler stops={stops} lineCount={diff.lineCount} onPick={nav.goTo} />}
<div className="mx-auto box-content max-w-[880px] px-6 py-5">
{qa.state.available && (
<div className="mb-4">
<AskThread
history={historyOf(qa.state, PATH_ASK_ANCHOR)}
asks={asksOf(qa.state, PATH_ASK_ANCHOR)}
open
hint={qa.state.subject === 'folder' ? '这个文件夹' : '这个文件'}
autoFocus={false}
focusToken={pathFocus}
closable={false}
profiles={qa.state.profiles}
onSelectProfile={qa.onSelectProfile}
onAsk={(question, images) => qa.onAsk(PATH_ASK_ANCHOR, question, images)}
onStop={qa.onStop}
onDelete={entryId => qa.onDelete(PATH_ASK_ANCHOR, entryId)}
onClose={() => undefined}
/>
</div>
)}
{diff?.newFile !== undefined && doc.error === undefined && <NewFileBanner detail={diff.newFile} />}
{doc.error !== undefined && (
<div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2">
@@ -205,7 +231,7 @@ export function App(): ReactElement | null {
</div>
)}
{doc.blocks.length === 0
? doc.error === undefined && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>
? doc.error === undefined && qa.state.subject !== 'folder' && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>
: doc.blocks.some(block => !filter.hidden.has(block.id))
? <BlockLevel blocks={doc.blocks} tree={tree} />
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
+33 -6
View File
@@ -138,22 +138,38 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url
}
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose, onOpenImage }: {
function AskInput({ running, first, hint, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
running: Ask | undefined
/** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */
/** 还没问过:提示「问…」,问过了是「继续问…」 */
first: boolean
/** 「问」后面的对象:块下面是「这块代码」,顶部是「这个文件」或「这个文件夹」 */
hint: string
profiles: ProfileChoice
onSelectProfile: (id: string) => void
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onClose: () => void
/** Esc 收起。顶部那条一直摊开,不收 */
closable: boolean
onOpenImage: (url: string) => void
/** 展开时把光标放进输入框。顶部那条一直摊开,不要抢阅读时光标 */
autoFocus: boolean
/** 大于 0 时再放一次光标;侧栏「问 AI」打开已经在的预览时用 */
focusToken: number
}): ReactElement {
const [value, setValue] = useState('')
const [images, setImages] = useState<PendingImage[]>([])
/** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */
const [notice, setNotice] = useState<string>()
const textarea = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (autoFocus)
textarea.current?.focus()
}, [autoFocus])
useEffect(() => {
if (focusToken > 0)
textarea.current?.focus()
}, [focusToken])
const attach = (files: File[]): void => {
if (files.length === 0)
return
@@ -179,7 +195,7 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on
event.preventDefault()
send()
}
else if (event.key === 'Escape') {
else if (closable && event.key === 'Escape') {
event.preventDefault()
onClose()
}
@@ -211,9 +227,8 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on
<textarea
ref={textarea}
rows={1}
autoFocus
value={value}
placeholder={`${first ? '问这块代码…' : '继续问…'} Enter 发送,Esc 收起`}
placeholder={`${first ? `问${hint}…` : '继续问…'} Enter 发送${closable ? ',Esc 收起' : ''}`}
aria-label="问 AI 的问题"
onChange={event => setValue(event.target.value)}
onKeyDown={onKeyDown}
@@ -251,6 +266,14 @@ export interface AskThreadProps {
open: boolean
/** 输入框旁边的 profile 下拉 */
profiles: ProfileChoice
/** 「问」后面的对象,默认「这块代码」 */
hint?: string
/** 展开时聚焦输入框,默认聚焦。一直摊开的顶部聊天不聚焦 */
autoFocus?: boolean
/** 变大时把光标放进输入框 */
focusToken?: number
/** Esc 能不能收起,默认能。顶部聊天不收 */
closable?: boolean
now?: number
onSelectProfile: (id: string) => void
/** images 是贴进输入框的图片,随问题一起发 */
@@ -264,7 +287,7 @@ export interface AskThreadProps {
* 块下面的问答区(左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
* 没展开但问过的块是一行「问过 N 次」、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里问过什么。
*/
export function AskThread({ history, asks, open, profiles, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
export function AskThread({ history, asks, open, profiles, hint = '这块代码', autoFocus = true, focusToken = 0, closable = true, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(true)
/** 正在看大图的那张 */
const [preview, setPreview] = useState<string>()
@@ -295,10 +318,14 @@ export function AskThread({ history, asks, open, profiles, now = Date.now(), onS
first={history.length === 0 && asks.length === 0}
profiles={profiles}
onSelectProfile={onSelectProfile}
hint={hint}
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
closable={closable}
onOpenImage={setPreview}
autoFocus={autoFocus}
focusToken={focusToken}
/>
{lightbox}
</section>
+20 -2
View File
@@ -1,7 +1,7 @@
import type { QaAction, QaState } from './qa'
import type { QaMessage, QaThreadView } from './types'
import { describe, expect, it } from 'vitest'
import { answerPreview, asksOf, EMPTY_QA, historyOf, qaReducer, relativeTime } from './qa'
import { answerPreview, asksOf, EMPTY_QA, historyOf, PATH_ASK_ANCHOR, qaReducer, relativeTime } from './qa'
const IF = 'function get_user > if @3fa1c2d0'
@@ -10,7 +10,7 @@ function thread(anchor: string, ...ids: string[]): QaThreadView {
}
function message(threads: QaThreadView[], running: QaMessage['running'] = []): QaMessage {
return { type: 'qa', available: true, threads, running }
return { type: 'qa', available: true, threads, path: [], subject: 'file', running }
}
function qa(threads: QaThreadView[], running: QaMessage['running'] = []): QaAction {
@@ -42,6 +42,24 @@ describe('问 AI 的状态', () => {
expect(state.threads).toHaveLength(1)
})
it('顶部的问答记在路径上,不进任何一块的历史', () => {
const state = run({
type: 'qa',
message: {
type: 'qa',
available: true,
threads: [],
path: [{ id: 'p', question: '这文件夹干什么', answer: '放源码', createdAt: '2026-09-25T10:00:00.000Z' }],
subject: 'folder',
running: [],
},
})
expect(state.path.map(entry => entry.id)).toEqual(['p'])
expect(state.subject).toBe('folder')
expect(historyOf(state, PATH_ASK_ANCHOR).map(entry => entry.id)).toEqual(['p'])
expect(historyOf(state, IF)).toEqual([])
})
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
let state = run(
qa([thread(IF, 'e1')]),
+25 -4
View File
@@ -1,6 +1,7 @@
/**
* 「问 AI」在页面上的状态和规则(不碰 DOM):问答按块的记号(BlockView.anchor)挂到块下面;
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史;记号对不上任何块的问答不显示。
* 「问 AI」在页面上的状态和规则(不碰 DOM):块下面的问答按块的记号挂;
* 顶部的问答按路径记在 path 上,不挂到任何一块。
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史。
*/
import type { AnswerEnd, ProfileChoice, QaEntryView, QaMessage, QaThreadView } from './types'
@@ -23,10 +24,17 @@ export interface Ask {
entryId?: string
}
/** 顶部问答的记号。跟扩展端 pathQa 里的是同一个,块的记号不会是这个。 */
export const PATH_ASK_ANCHOR = '\0path'
export interface QaState {
/** 文件不在工作区文件夹里时问答没地方存,不给「问 AI」 */
available: boolean
threads: QaThreadView[]
/** 这一页顶部按路径记的问答 */
path: QaEntryView[]
/** 顶部问的是这个文件还是这个文件夹 */
subject: 'file' | 'folder'
/** 当前这一轮问的,按先后 */
asks: Ask[]
/** 展开着对话区(历史、当前这一轮、输入框)的块 */
@@ -35,7 +43,15 @@ export interface QaState {
profiles: ProfileChoice
}
export const EMPTY_QA: QaState = { available: false, threads: [], asks: [], open: [], profiles: { options: [], selected: '' } }
export const EMPTY_QA: QaState = {
available: false,
threads: [],
path: [],
subject: 'file',
asks: [],
open: [],
profiles: { options: [], selected: '' },
}
export type QaAction
= | { type: 'qa', message: QaMessage }
@@ -60,6 +76,8 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
...state,
available: action.message.available,
threads: action.message.threads,
path: action.message.path,
subject: action.message.subject,
asks: [...state.asks, ...adopted.map((ask): Ask => ({ ...ask, status: 'running' }))],
open: adopted.reduce((open, ask) => withOpen(open, ask.anchor), state.open),
}
@@ -108,7 +126,10 @@ export function asksOf(state: QaState, anchor: string): Ask[] {
/** anchor 那块的灰字历史:存下的问答,当前这一轮问的那几条除外 */
export function historyOf(state: QaState, anchor: string): QaEntryView[] {
const current = new Set(asksOf(state, anchor).map(ask => ask.entryId))
return state.threads.find(thread => thread.anchor === anchor)?.entries.filter(entry => !current.has(entry.id)) ?? []
const entries = anchor === PATH_ASK_ANCHOR
? state.path
: state.threads.find(thread => thread.anchor === anchor)?.entries ?? []
return entries.filter(entry => !current.has(entry.id))
}
const MINUTE = 60_000
@@ -266,6 +266,10 @@ export interface QaMessage {
type: 'qa'
available: boolean
threads: QaThreadView[]
/** 这一页顶部、按路径记的问答;不挂在块上 */
path: QaEntryView[]
/** 顶部问的是这个文件还是这个文件夹 */
subject: 'file' | 'folder'
running: RunningAsk[]
}
@@ -422,6 +426,7 @@ export type ExtensionToWebview
| { type: 'repoLinkBroken', path: string, symbol?: string }
| { type: 'repoLinkOpened', path: string, symbol?: string }
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
| { type: 'focusPathAsk' }
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)