✨ feat(vscode): 文件和文件夹在预览顶部问
侧栏「问 AI」打开代码阅读预览。文件夹是没有代码块的一页,源码文件在整页顶部聊,代码块下面的问答不动。历史按工作区文件夹和相对路径记在窗口状态里,不写进按块存的记录。
This commit is contained in:
1 parent
2be635d1e5
commit
2c10acf9c4
19 files changed
+610
-459
No files matched your search
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')]),
|
||||
|
||||
@@ -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(扩展端给每行补回原来第一行的缩进)
|
||||
|
||||
Reference in new issue
Block a user