⚡ perf(vscode): 看板打开先出快照、评论只拉末页
This commit is contained in:
1 parent
904ab7491d
commit
a68caf5f6e
9 files changed
+447
-115
No files matched your search
@@ -13,7 +13,6 @@ import { randomBytes } from 'node:crypto'
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
|
||||
import { loginForToken } from '../auth/identity'
|
||||
import { deleteToken, getToken } from '../auth/secrets'
|
||||
import { detectRepo } from '../git/remote'
|
||||
import {
|
||||
@@ -43,6 +42,7 @@ import * as worktree from './handlers/worktree'
|
||||
import * as youtrackIssues from './handlers/youtrackIssues'
|
||||
import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor'
|
||||
import { KANBAN_SCOPE_KEY } from './kanbanScope'
|
||||
import { KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot, snapshotScriptTag } from './kanbanSnapshot'
|
||||
|
||||
/** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */
|
||||
export { getDefaultProfilePath } from '../cc/profiles'
|
||||
@@ -62,6 +62,10 @@ export class KanbanWebviewPanel {
|
||||
* alone is an unambiguous key.
|
||||
*/
|
||||
private issueRefs = new Map<number, { source: 'gitea' | 'youtrack', externalId?: string }>()
|
||||
/** webview 已发过 issues/refresh,此后才 postMessage,避免启动时消息丢。 */
|
||||
private webviewReady = false
|
||||
private loadInFlight: Promise<void> | undefined
|
||||
private lastIssuesUpdate: Extract<ExtensionToWebview, { type: 'issues/update' }> | undefined
|
||||
/**
|
||||
* sessionId → its dedicated terminal in the editor area. Populated when the
|
||||
* user presses Enter on a card; entry is removed when the terminal is
|
||||
@@ -232,6 +236,8 @@ export class KanbanWebviewPanel {
|
||||
}
|
||||
|
||||
this.panel.webview.html = this.buildHtml()
|
||||
// 和 webview JS 启动重叠:有快照则 HTML 已带上一屏,这里后台拉新。
|
||||
void this.loadAndPush()
|
||||
|
||||
// Let the always-on webhook coordinator know who to refresh + toast for.
|
||||
webhookCoordinator.setActivePanel(this)
|
||||
@@ -342,6 +348,15 @@ export class KanbanWebviewPanel {
|
||||
|
||||
private handleMessage(msg: WebviewToExtension): void {
|
||||
if (msg.type === 'issues/refresh') {
|
||||
const first = !this.webviewReady
|
||||
this.webviewReady = true
|
||||
if (this.loadInFlight)
|
||||
return
|
||||
if (first && this.lastIssuesUpdate) {
|
||||
this.postMessage(this.lastIssuesUpdate)
|
||||
this.postMessage({ type: 'commit/has-changes', value: this.hasGitChanges })
|
||||
return
|
||||
}
|
||||
void this.loadAndPush()
|
||||
return
|
||||
}
|
||||
@@ -835,13 +850,38 @@ export class KanbanWebviewPanel {
|
||||
return terminals.withLiveTerminalTabState(this, issues)
|
||||
}
|
||||
|
||||
private async loadYouTrackSafe(): Promise<Issue[]> {
|
||||
try {
|
||||
return await loadYouTrackIssues(this.context)
|
||||
}
|
||||
catch (ytErr) {
|
||||
const message = ytErr instanceof Error ? ytErr.message : String(ytErr)
|
||||
logger.add({ level: 'error', source: 'youtrack', message: '加载 YouTrack 问题失败', details: message })
|
||||
this.postMessage({
|
||||
type: 'toast/show',
|
||||
id: makeNonce(),
|
||||
level: 'error',
|
||||
message: `YouTrack 同步失败:${message}`,
|
||||
dismissOnTimer: 6000,
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads issues from gitea and pushes to the webview. Public so the
|
||||
* always-on webhook coordinator can refresh us when a PR event arrives.
|
||||
*/
|
||||
async loadAndPush(): Promise<void> {
|
||||
this.postMessage({ type: 'issues/loading' })
|
||||
if (this.loadInFlight)
|
||||
return this.loadInFlight
|
||||
this.loadInFlight = this.loadAndPushInner().finally(() => {
|
||||
this.loadInFlight = undefined
|
||||
})
|
||||
return this.loadInFlight
|
||||
}
|
||||
|
||||
private async loadAndPushInner(): Promise<void> {
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[superpowers/panel] loadAndPush workspaceRoot=', workspaceRoot)
|
||||
@@ -900,24 +940,10 @@ export class KanbanWebviewPanel {
|
||||
|
||||
const scope = this.context.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine'
|
||||
try {
|
||||
const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot, scope })
|
||||
// YouTrack is a best-effort second source: a failure here must never
|
||||
// break the gitea board, so swallow it into a toast + log.
|
||||
let youtrackList: Issue[] = []
|
||||
try {
|
||||
youtrackList = await loadYouTrackIssues(this.context)
|
||||
}
|
||||
catch (ytErr) {
|
||||
const message = ytErr instanceof Error ? ytErr.message : String(ytErr)
|
||||
logger.add({ level: 'error', source: 'youtrack', message: '加载 YouTrack 问题失败', details: message })
|
||||
this.postMessage({
|
||||
type: 'toast/show',
|
||||
id: makeNonce(),
|
||||
level: 'error',
|
||||
message: `YouTrack 同步失败:${message}`,
|
||||
dismissOnTimer: 6000,
|
||||
})
|
||||
}
|
||||
const [{ issues: giteaIssues, me }, youtrackList] = await Promise.all([
|
||||
loadIssues({ host, token, owner, repo, workspaceRoot, scope }),
|
||||
this.loadYouTrackSafe(),
|
||||
])
|
||||
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,附加本机 codex
|
||||
// 会话文件存在性,再补 live terminal 态。
|
||||
const issues = this.withLiveTerminalTabState(
|
||||
@@ -929,19 +955,29 @@ export class KanbanWebviewPanel {
|
||||
issues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
|
||||
)
|
||||
const ytSettings = getSettings(this.context)
|
||||
const me = await loginForToken(host, token)
|
||||
this.postMessage({
|
||||
const payload: Extract<ExtensionToWebview, { type: 'issues/update' }> = {
|
||||
type: 'issues/update',
|
||||
issues,
|
||||
scope,
|
||||
globalAutoReview: ytSettings.autoReview,
|
||||
youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '',
|
||||
me,
|
||||
}
|
||||
this.lastIssuesUpdate = payload
|
||||
void this.context.workspaceState.update(KANBAN_SNAPSHOT_KEY, {
|
||||
issues,
|
||||
scope,
|
||||
me,
|
||||
globalAutoReview: payload.globalAutoReview,
|
||||
youtrackConfigured: payload.youtrackConfigured,
|
||||
})
|
||||
// Re-publish the cached git state so a slow webview boot (or a
|
||||
// refresh after git events already fired) still picks up the latest
|
||||
// value. Cheap — the webview discards if equal.
|
||||
this.postMessage({ type: 'commit/has-changes', value: this.hasGitChanges })
|
||||
if (this.webviewReady) {
|
||||
this.postMessage(payload)
|
||||
// Re-publish the cached git state so a slow webview boot (or a
|
||||
// refresh after git events already fired) still picks up the latest
|
||||
// value. Cheap — the webview discards if equal.
|
||||
this.postMessage({ type: 'commit/has-changes', value: this.hasGitChanges })
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof GiteaApiError && err.status === 401) {
|
||||
@@ -1174,6 +1210,14 @@ export class KanbanWebviewPanel {
|
||||
`<head>\n <meta http-equiv="Content-Security-Policy" content="${csp}">`,
|
||||
)
|
||||
|
||||
const snapshot = parseKanbanSnapshot(this.context.workspaceState.get(KANBAN_SNAPSHOT_KEY))
|
||||
if (snapshot) {
|
||||
html = html.replace(
|
||||
'</body>',
|
||||
` ${snapshotScriptTag(nonce, snapshot)}\n </body>`,
|
||||
)
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1597,7 +1597,7 @@ export async function resolveLockedReason(
|
||||
const token = await getToken(panel.context, remote.host)
|
||||
if (!token)
|
||||
return { locked: false }
|
||||
const issues = await loadIssues({
|
||||
const { issues } = await loadIssues({
|
||||
host: remote.host,
|
||||
token,
|
||||
owner: remote.owner,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseKanbanSnapshot, snapshotScriptTag, snapshotToScriptJson } from './kanbanSnapshot'
|
||||
|
||||
const sample = {
|
||||
issues: [{ id: 'o/r#1', number: 1, title: '</script><img>', column: 'todo' as const, assignees: [], htmlUrl: 'https://x' }],
|
||||
scope: 'mine' as const,
|
||||
me: 'cruldra',
|
||||
globalAutoReview: true,
|
||||
youtrackConfigured: false,
|
||||
}
|
||||
|
||||
describe('kanbanSnapshot', () => {
|
||||
it('往返:注入 JSON 不含原始 </script>,parse 还原标题', () => {
|
||||
const json = snapshotToScriptJson(sample)
|
||||
expect(json).not.toContain('</script>')
|
||||
expect(json).toContain('\\u003c')
|
||||
const parsed = parseKanbanSnapshot(JSON.parse(json))
|
||||
expect(parsed?.issues[0].title).toBe('</script><img>')
|
||||
expect(parsed?.scope).toBe('mine')
|
||||
})
|
||||
|
||||
it('script 标签带 nonce 和 id', () => {
|
||||
const tag = snapshotScriptTag('abc', sample)
|
||||
expect(tag).toContain('id="kanban-snapshot"')
|
||||
expect(tag).toContain('nonce="abc"')
|
||||
expect(tag).toContain('type="application/json"')
|
||||
expect(tag).not.toContain('</script><img>')
|
||||
})
|
||||
|
||||
it('垃圾数据返回 null', () => {
|
||||
expect(parseKanbanSnapshot(null)).toBeNull()
|
||||
expect(parseKanbanSnapshot({ scope: 'mine' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Issue } from '../gitea/types'
|
||||
|
||||
export const KANBAN_SNAPSHOT_KEY = 'superpowers.kanbanSnapshot'
|
||||
|
||||
export interface KanbanSnapshot {
|
||||
issues: Issue[]
|
||||
scope: 'mine' | 'all'
|
||||
me?: string
|
||||
globalAutoReview: boolean
|
||||
youtrackConfigured: boolean
|
||||
}
|
||||
|
||||
export function parseKanbanSnapshot(raw: unknown): KanbanSnapshot | null {
|
||||
if (!raw || typeof raw !== 'object')
|
||||
return null
|
||||
const obj = raw as Record<string, unknown>
|
||||
if (!Array.isArray(obj.issues))
|
||||
return null
|
||||
if (obj.scope !== 'mine' && obj.scope !== 'all')
|
||||
return null
|
||||
return {
|
||||
issues: obj.issues as Issue[],
|
||||
scope: obj.scope,
|
||||
me: typeof obj.me === 'string' ? obj.me : undefined,
|
||||
globalAutoReview: obj.globalAutoReview === true,
|
||||
youtrackConfigured: obj.youtrackConfigured === true,
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入 HTML 的 JSON:小于号换成 \\u003c,避免 script 标签被提前关掉。 */
|
||||
export function snapshotToScriptJson(snapshot: KanbanSnapshot): string {
|
||||
return JSON.stringify(snapshot).replace(/</g, '\\u003c')
|
||||
}
|
||||
|
||||
export function snapshotScriptTag(nonce: string, snapshot: KanbanSnapshot): string {
|
||||
return `<script nonce="${nonce}" type="application/json" id="kanban-snapshot">${snapshotToScriptJson(snapshot)}</script>`
|
||||
}
|
||||
Reference in new issue
Block a user