⚡ perf(vscode): 看板打开先出快照、评论只拉末页

This commit is contained in:
cruldra committed 2026-09-01 07:09:55 +08:00
1 parent 904ab7491d
commit a68caf5f6e
9 files changed
+447 -115

No files matched your search

+26 -11
View File
@@ -13,7 +13,7 @@ import { Buffer } from 'node:buffer'
import { logger } from '../logging/logger' import { logger } from '../logging/logger'
const PAGE_SIZE = 50 export const PAGE_SIZE = 50
export interface GiteaUser { export interface GiteaUser {
login: string login: string
@@ -226,6 +226,29 @@ export async function listAllRepoComments(opts: {
return out return out
} }
/**
* 单页拉取某工单评论。Gitea 默认旧→新,末页才是最新 state JSON 所在。
*/
export async function listIssueCommentsPage(opts: {
host: string
token: string
owner: string
repo: string
index: number
page: number
}): Promise<GiteaComment[]> {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`,
)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(opts.page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as GiteaComment[]
return Array.isArray(batch) ? batch : []
}
/** /**
* Fetches comments for a single issue via * Fetches comments for a single issue via
* `/repos/{owner}/{repo}/issues/{index}/comments`. Paginated until a short * `/repos/{owner}/{repo}/issues/{index}/comments`. Paginated until a short
@@ -243,16 +266,8 @@ export async function listIssueComments(opts: {
let page = 1 let page = 1
while (true) { while (true) {
const url = new URL( const batch = await listIssueCommentsPage({ ...opts, page })
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`, if (batch.length === 0)
)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as GiteaComment[]
if (!Array.isArray(batch) || batch.length === 0)
break break
out.push(...batch) out.push(...batch)
if (batch.length < PAGE_SIZE) if (batch.length < PAGE_SIZE)
+154
View File
@@ -0,0 +1,154 @@
import type { GiteaComment, GiteaIssue } from './api'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getCurrentUser = vi.fn()
const listIssuesByFilter = vi.fn()
const listIssueCommentsPage = vi.fn()
const listAllRepoComments = vi.fn()
const getDependencies = vi.fn()
const getPullRequest = vi.fn()
const postIssueComment = vi.fn()
const getIssue = vi.fn()
const listIssueComments = vi.fn()
vi.mock('./api', () => ({
PAGE_SIZE: 50,
getCurrentUser,
listIssuesByFilter,
listIssueCommentsPage,
listAllRepoComments,
getDependencies,
getPullRequest,
postIssueComment,
getIssue,
listIssueComments,
}))
function issue(n: number, extra: Partial<GiteaIssue> = {}): GiteaIssue {
return {
id: n,
number: n,
title: `Issue ${n}`,
state: 'open',
comments: 0,
created_at: '2020-01-01T00:00:00Z',
updated_at: '2020-01-01T00:00:00Z',
body: '',
html_url: `https://gitea.example/o/r/issues/${n}`,
user: { login: 'me' },
assignees: [{ login: 'me' }],
labels: [],
...extra,
}
}
function comment(body: string, index: number, id: number): GiteaComment {
return {
id,
body,
issue_url: `https://gitea.example/api/v1/repos/o/r/issues/${index}`,
created_at: `2020-01-01T00:00:${String(id).padStart(2, '0')}Z`,
updated_at: `2020-01-01T00:00:${String(id).padStart(2, '0')}Z`,
}
}
describe('listCommentsUntilColumn', () => {
beforeEach(() => {
listIssueCommentsPage.mockReset()
})
it('commentCount 为 0 时不发请求', async () => {
const { listCommentsUntilColumn } = await import('./issueLoader.js')
const out = await listCommentsUntilColumn({
host: 'gitea.example',
token: 't',
owner: 'o',
repo: 'r',
index: 1,
commentCount: 0,
})
expect(out).toEqual([])
expect(listIssueCommentsPage).not.toHaveBeenCalled()
})
it('先拉末页,没有 column JSON 再往前翻', async () => {
const { listCommentsUntilColumn } = await import('./issueLoader.js')
listIssueCommentsPage.mockImplementation(async (opts: { page: number }) => {
if (opts.page === 2)
return [comment('later discussion', 1, 51)]
if (opts.page === 1)
return [comment(JSON.stringify({ column: 'in-progress' }), 1, 1)]
return []
})
const out = await listCommentsUntilColumn({
host: 'gitea.example',
token: 't',
owner: 'o',
repo: 'r',
index: 1,
commentCount: 51,
})
expect(listIssueCommentsPage.mock.calls.map(c => (c[0] as { page: number }).page)).toEqual([2, 1])
expect(out.at(-1)?.body).toContain('later discussion')
expect(out[0]?.body).toContain('in-progress')
})
})
describe('loadIssues', () => {
beforeEach(() => {
getCurrentUser.mockReset()
listIssuesByFilter.mockReset()
listIssueCommentsPage.mockReset()
listAllRepoComments.mockReset()
getDependencies.mockReset()
getPullRequest.mockReset()
postIssueComment.mockReset()
getCurrentUser.mockResolvedValue({ login: 'me', id: 1 })
listIssuesByFilter.mockResolvedValue([])
listIssueCommentsPage.mockResolvedValue([])
getDependencies.mockResolvedValue([])
postIssueComment.mockResolvedValue({})
})
it('不走整仓评论消防水管,只按工单拉末页', async () => {
const { loadIssues } = await import('./issueLoader.js')
listIssuesByFilter.mockResolvedValue([issue(7, { comments: 3 })])
listIssueCommentsPage.mockResolvedValue([
comment(JSON.stringify({ column: 'todo' }), 7, 1),
])
const { issues, me } = await loadIssues({
host: 'gitea.example',
token: 't',
owner: 'o',
repo: 'r',
scope: 'all',
})
expect(me).toBe('me')
expect(listAllRepoComments).not.toHaveBeenCalled()
expect(listIssueCommentsPage).toHaveBeenCalledWith(expect.objectContaining({ index: 7, page: 1 }))
expect(issues).toHaveLength(1)
expect(issues[0].column).toBe('todo')
})
it('缺 state 评论时 POST 不挡住返回', async () => {
const { loadIssues } = await import('./issueLoader.js')
listIssuesByFilter.mockResolvedValue([issue(3, { comments: 0 })])
postIssueComment.mockReturnValue(new Promise(() => {}))
const started = Date.now()
const { issues } = await loadIssues({
host: 'gitea.example',
token: 't',
owner: 'o',
repo: 'r',
scope: 'all',
})
expect(Date.now() - started).toBeLessThan(500)
expect(issues[0].column).toBe('todo')
expect(postIssueComment).toHaveBeenCalled()
})
})
+80 -67
View File
@@ -4,17 +4,10 @@
* state-comment convention. * state-comment convention.
* *
* Flow: * Flow:
* 1. Kick off `/user` and the repo-wide comments firehose concurrently. * 1. 拉工单列表(mine:先 /user 再 assigned+created;all:工单与 /user 并行)。
* Once `/user` resolves we know `login`, so fire both issue-filter * 2. 每张工单只从评论末页往前翻,直到碰到带 column 的 state JSON。
* queries (`assigned_by`, `created_by`) in parallel with the still- * 不拉整仓库评论。缺 state 时内存用默认列,POST 不挡返回。
* pending comments fetch. * 3. 非 done 工单并行补依赖和 PR 状态。
* 2. Merge the two issue lists by `number`.
* 3. Group comments by issue number (parsed from each comment's `issue_url`).
* 4. For each merged issue, inspect the literal last comment for
* `{ "column": "<id>" }`. If it parses cleanly, use that column.
* Otherwise compute a default from `issue.state` and persist it by
* posting a JSON comment. POST failures are logged and ignored — we
* still display the issue in the computed column.
*/ */
import type { GiteaComment, GiteaIssue } from './api' import type { GiteaComment, GiteaIssue } from './api'
@@ -27,9 +20,10 @@ import {
getDependencies, getDependencies,
getIssue, getIssue,
getPullRequest, getPullRequest,
listAllRepoComments,
listIssueComments, listIssueComments,
listIssueCommentsPage,
listIssuesByFilter, listIssuesByFilter,
PAGE_SIZE,
postIssueComment, postIssueComment,
} from './api' } from './api'
@@ -219,16 +213,6 @@ function parseColumnFromComments(comments: GiteaComment[]): {
return { column: null } return { column: null }
} }
/** Extracts the issue index from a comment's `issue_url`, e.g. `.../issues/42`. */
function indexFromIssueUrl(issueUrl: string): number | undefined {
if (!issueUrl)
return undefined
const segments = issueUrl.split('/')
const last = segments[segments.length - 1]
const n = Number(last)
return Number.isFinite(n) ? n : undefined
}
/** Merges issue lists by `number`, preferring the first occurrence. */ /** Merges issue lists by `number`, preferring the first occurrence. */
function mergeIssues(...lists: GiteaIssue[][]): GiteaIssue[] { function mergeIssues(...lists: GiteaIssue[][]): GiteaIssue[] {
const map = new Map<number, GiteaIssue>() const map = new Map<number, GiteaIssue>()
@@ -241,26 +225,49 @@ function mergeIssues(...lists: GiteaIssue[][]): GiteaIssue[] {
return [...map.values()] return [...map.values()]
} }
const COMMENT_FETCH_CONCURRENCY = 8
async function mapPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = Array.from({ length: items.length })
let next = 0
async function worker(): Promise<void> {
while (true) {
const i = next++
if (i >= items.length)
return
out[i] = await fn(items[i])
}
}
const n = Math.min(Math.max(limit, 1), items.length)
if (n === 0)
return out
await Promise.all(Array.from({ length: n }, () => worker()))
return out
}
/** /**
* Groups repo-wide comments by issue number, ordered ascending by creation time. * 从末页往前拼评论,直到 parse 出 column 或翻到第 1 页。
* Comments whose `issue_url` cannot be parsed are dropped. * commentCount=0 不发请求。
*/ */
function groupCommentsByIssue(comments: GiteaComment[]): Map<number, GiteaComment[]> { export async function listCommentsUntilColumn(opts: {
const buckets = new Map<number, GiteaComment[]>() host: string
for (const c of comments) { token: string
const idx = indexFromIssueUrl(c.issue_url) owner: string
if (idx === undefined) repo: string
continue index: number
const bucket = buckets.get(idx) commentCount: number
if (bucket) }): Promise<GiteaComment[]> {
bucket.push(c) if (opts.commentCount <= 0)
else return []
buckets.set(idx, [c]) const lastPage = Math.max(1, Math.ceil(opts.commentCount / PAGE_SIZE))
let comments: GiteaComment[] = []
for (let page = lastPage; page >= 1; page--) {
const batch = await listIssueCommentsPage({ ...opts, page })
comments = [...batch, ...comments]
if (parseColumnFromComments(comments).column)
break
} }
for (const bucket of buckets.values()) { return comments
bucket.sort((a, b) => a.created_at.localeCompare(b.created_at))
}
return buckets
} }
/** /**
@@ -352,21 +359,17 @@ async function buildIssue(opts: {
} }
else { else {
column = defaultColumnForState(issue.state) column = defaultColumnForState(issue.state)
// Persist the default so future pulls are cheap; failures are // 缺省列先用于本屏;POST 不挡返回,失败只记 warn。
// non-fatal — we still display the issue in the computed column. void postIssueComment({
try { host,
await postIssueComment({ token,
host, owner,
token, repo,
owner, index: issue.number,
repo, body: JSON.stringify({ column }),
index: issue.number, }).catch((postErr) => {
body: JSON.stringify({ column }),
})
}
catch (postErr) {
console.warn(`[superpowers] failed to seed state comment on ${id}:`, postErr) console.warn(`[superpowers] failed to seed state comment on ${id}:`, postErr)
} })
} }
// Synchronously stat the worktree path so the UI can render it as a // Synchronously stat the worktree path so the UI can render it as a
@@ -423,34 +426,44 @@ export async function loadIssues(opts: {
workspaceRoot?: string workspaceRoot?: string
/** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */ /** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */
scope?: 'mine' | 'all' scope?: 'mine' | 'all'
}): Promise<Issue[]> { }): Promise<{ issues: Issue[], me: string }> {
const { host, token, owner, repo, workspaceRoot } = opts const { host, token, owner, repo, workspaceRoot } = opts
// The repo-wide comments firehose doesn't depend on scope; kick it off first. // ① 工单列表 + 当前用户
const commentsPromise = listAllRepoComments({ host, token, owner, repo })
let merged: Awaited<ReturnType<typeof listIssuesByFilter>> let merged: Awaited<ReturnType<typeof listIssuesByFilter>>
let allComments: Awaited<typeof commentsPromise> let me: string
if (opts.scope === 'all') { if (opts.scope === 'all') {
;[merged, allComments] = await Promise.all([ const [all, user] = await Promise.all([
listIssuesByFilter({ host, token, owner, repo }), listIssuesByFilter({ host, token, owner, repo }),
commentsPromise, getCurrentUser({ host, token }),
]) ])
merged = all
me = user.login
} }
else { else {
// `/user` validates the token and gives us the login. The two
// issue-filter calls need `user.login` and run together after.
const user = await getCurrentUser({ host, token }) const user = await getCurrentUser({ host, token })
const [assigned, created, comments] = await Promise.all([ me = user.login
const [assigned, created] = await Promise.all([
listIssuesByFilter({ host, token, owner, repo, filter: 'assigned_by', user: user.login }), listIssuesByFilter({ host, token, owner, repo, filter: 'assigned_by', user: user.login }),
listIssuesByFilter({ host, token, owner, repo, filter: 'created_by', user: user.login }), listIssuesByFilter({ host, token, owner, repo, filter: 'created_by', user: user.login }),
commentsPromise,
]) ])
merged = mergeIssues(assigned, created) merged = mergeIssues(assigned, created)
allComments = comments
} }
merged = merged.filter(issue => !isMailboxIssue(issue)) merged = merged.filter(issue => !isMailboxIssue(issue))
const buckets = groupCommentsByIssue(allComments)
// ② 每张工单从评论末页往前翻,直到有 column
const commentLists = await mapPool(merged, COMMENT_FETCH_CONCURRENCY, issue =>
listCommentsUntilColumn({
host,
token,
owner,
repo,
index: issue.number,
commentCount: issue.comments,
}))
const buckets = new Map<number, GiteaComment[]>()
for (let i = 0; i < merged.length; i++)
buckets.set(merged[i].number, commentLists[i])
// 先用各工单评论 bucket 算出 column,决定是否需要 per-issue 远程拉取。 // 先用各工单评论 bucket 算出 column,决定是否需要 per-issue 远程拉取。
// 完成(done)工单的依赖(前置阻塞)无意义、PR 状态永久不变(已合并), // 完成(done)工单的依赖(前置阻塞)无意义、PR 状态永久不变(已合并),
@@ -509,7 +522,7 @@ export async function loadIssues(opts: {
})) }))
} }
return resolved return { issues: resolved, me }
} }
/** /**
+70 -26
View File
@@ -13,7 +13,6 @@ import { randomBytes } from 'node:crypto'
import * as fs from 'node:fs' import * as fs from 'node:fs'
import * as path from 'node:path' import * as path from 'node:path'
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode' import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
import { loginForToken } from '../auth/identity'
import { deleteToken, getToken } from '../auth/secrets' import { deleteToken, getToken } from '../auth/secrets'
import { detectRepo } from '../git/remote' import { detectRepo } from '../git/remote'
import { import {
@@ -43,6 +42,7 @@ import * as worktree from './handlers/worktree'
import * as youtrackIssues from './handlers/youtrackIssues' import * as youtrackIssues from './handlers/youtrackIssues'
import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor' import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor'
import { KANBAN_SCOPE_KEY } from './kanbanScope' import { KANBAN_SCOPE_KEY } from './kanbanScope'
import { KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot, snapshotScriptTag } from './kanbanSnapshot'
/** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */ /** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */
export { getDefaultProfilePath } from '../cc/profiles' export { getDefaultProfilePath } from '../cc/profiles'
@@ -62,6 +62,10 @@ export class KanbanWebviewPanel {
* alone is an unambiguous key. * alone is an unambiguous key.
*/ */
private issueRefs = new Map<number, { source: 'gitea' | 'youtrack', externalId?: string }>() 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 * sessionId → its dedicated terminal in the editor area. Populated when the
* user presses Enter on a card; entry is removed when the terminal is * 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() this.panel.webview.html = this.buildHtml()
// 和 webview JS 启动重叠:有快照则 HTML 已带上一屏,这里后台拉新。
void this.loadAndPush()
// Let the always-on webhook coordinator know who to refresh + toast for. // Let the always-on webhook coordinator know who to refresh + toast for.
webhookCoordinator.setActivePanel(this) webhookCoordinator.setActivePanel(this)
@@ -342,6 +348,15 @@ export class KanbanWebviewPanel {
private handleMessage(msg: WebviewToExtension): void { private handleMessage(msg: WebviewToExtension): void {
if (msg.type === 'issues/refresh') { 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() void this.loadAndPush()
return return
} }
@@ -835,13 +850,38 @@ export class KanbanWebviewPanel {
return terminals.withLiveTerminalTabState(this, issues) 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 * Reloads issues from gitea and pushes to the webview. Public so the
* always-on webhook coordinator can refresh us when a PR event arrives. * always-on webhook coordinator can refresh us when a PR event arrives.
*/ */
async loadAndPush(): Promise<void> { 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 const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[superpowers/panel] loadAndPush workspaceRoot=', workspaceRoot) 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' const scope = this.context.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine'
try { try {
const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot, scope }) const [{ issues: giteaIssues, me }, youtrackList] = await Promise.all([
// YouTrack is a best-effort second source: a failure here must never loadIssues({ host, token, owner, repo, workspaceRoot, scope }),
// break the gitea board, so swallow it into a toast + log. this.loadYouTrackSafe(),
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,
})
}
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,附加本机 codex // 本机记录(会话 id / worktree 等)叠加在共享状态之上,附加本机 codex
// 会话文件存在性,再补 live terminal 态。 // 会话文件存在性,再补 live terminal 态。
const issues = this.withLiveTerminalTabState( 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), issues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
) )
const ytSettings = getSettings(this.context) const ytSettings = getSettings(this.context)
const me = await loginForToken(host, token) const payload: Extract<ExtensionToWebview, { type: 'issues/update' }> = {
this.postMessage({
type: 'issues/update', type: 'issues/update',
issues, issues,
scope, scope,
globalAutoReview: ytSettings.autoReview, globalAutoReview: ytSettings.autoReview,
youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '', youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '',
me, 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 if (this.webviewReady) {
// refresh after git events already fired) still picks up the latest this.postMessage(payload)
// value. Cheap — the webview discards if equal. // Re-publish the cached git state so a slow webview boot (or a
this.postMessage({ type: 'commit/has-changes', value: this.hasGitChanges }) // 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) { catch (err) {
if (err instanceof GiteaApiError && err.status === 401) { if (err instanceof GiteaApiError && err.status === 401) {
@@ -1174,6 +1210,14 @@ export class KanbanWebviewPanel {
`<head>\n <meta http-equiv="Content-Security-Policy" content="${csp}">`, `<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 return html
} }
} }
+1 -1
View File
@@ -1597,7 +1597,7 @@ export async function resolveLockedReason(
const token = await getToken(panel.context, remote.host) const token = await getToken(panel.context, remote.host)
if (!token) if (!token)
return { locked: false } return { locked: false }
const issues = await loadIssues({ const { issues } = await loadIssues({
host: remote.host, host: remote.host,
token, token,
owner: remote.owner, owner: remote.owner,
+34
View File
@@ -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()
})
})
+37
View File
@@ -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>`
}
+1 -1
View File
@@ -344,7 +344,7 @@ class WebhookCoordinator {
const tok = await getToken(this.ctx!, remote.host) const tok = await getToken(this.ctx!, remote.host)
if (!tok) if (!tok)
return null return null
const issues = await loadIssues({ host: remote.host, token: tok, owner: remote.owner, repo: remote.repo }) const { issues } = await loadIssues({ host: remote.host, token: tok, owner: remote.owner, repo: remote.repo })
const found = issues.find(i => i.branch === event.branch) const found = issues.find(i => i.branch === event.branch)
if (found) { if (found) {
logger.add({ logger.add({
+44 -9
View File
@@ -2,8 +2,8 @@
* Subscribes to the extension host's issue stream. * Subscribes to the extension host's issue stream.
* *
* State machine (`UseIssuesState`): * State machine (`UseIssuesState`):
* - loading : initial mount + any time the host pushes `issues/loading` * - loading : 无快照的首次挂载,或 host 推 `issues/loading` 且当前还没有列表
* - ready : host pushed `issues/update`; carries the current list * - ready : host 推了 `issues/update`,或 HTML 里注入了上一屏快照
* - error : host pushed `issues/error`; carries the user-facing message * - error : host pushed `issues/error`; carries the user-facing message
* *
* Settings UI is a separate overlay (`settings: SettingsOverlayState | null`) * Settings UI is a separate overlay (`settings: SettingsOverlayState | null`)
@@ -287,13 +287,48 @@ export interface UseIssuesResult {
isHandoffRunning: (issueNumber: number) => boolean isHandoffRunning: (issueNumber: number) => boolean
} }
function readInjectedSnapshot(): {
issues: Issue[]
scope: 'mine' | 'all'
me?: string
globalAutoReview: boolean
youtrackConfigured: boolean
} | null {
const el = document.getElementById('kanban-snapshot')
if (!el?.textContent)
return null
try {
const parsed: unknown = JSON.parse(el.textContent)
if (!parsed || typeof parsed !== 'object')
return null
const obj = parsed 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,
}
}
catch {
return null
}
}
export function useIssues(): UseIssuesResult { export function useIssues(): UseIssuesResult {
const [state, setState] = useState<UseIssuesState>({ status: 'loading' }) const injected = readInjectedSnapshot()
const [state, setState] = useState<UseIssuesState>(
injected ? { status: 'ready', issues: injected.issues } : { status: 'loading' },
)
const [settings, setSettings] = useState<SettingsOverlayState | null>(null) const [settings, setSettings] = useState<SettingsOverlayState | null>(null)
const [youtrackProjects, setYoutrackProjects] = useState<YouTrackProjectsState>({ status: 'idle', projects: [] }) const [youtrackProjects, setYoutrackProjects] = useState<YouTrackProjectsState>({ status: 'idle', projects: [] })
const [globalAutoReview, setGlobalAutoReview] = useState<boolean>(true) const [globalAutoReview, setGlobalAutoReview] = useState<boolean>(injected?.globalAutoReview ?? true)
const [scope, setScope] = useState<'mine' | 'all'>('mine') const [scope, setScope] = useState<'mine' | 'all'>(injected?.scope ?? 'mine')
const [youtrackConfigured, setYoutrackConfigured] = useState<boolean>(false) const [youtrackConfigured, setYoutrackConfigured] = useState<boolean>(injected?.youtrackConfigured ?? false)
const [toasts, setToasts] = useState<ToastItem[]>([]) const [toasts, setToasts] = useState<ToastItem[]>([])
const [profiles, setProfiles] = useState<ClaudeProfile[]>([]) const [profiles, setProfiles] = useState<ClaudeProfile[]>([])
const [logs, setLogs] = useState<LogEntry[]>([]) const [logs, setLogs] = useState<LogEntry[]>([])
@@ -307,7 +342,7 @@ export function useIssues(): UseIssuesResult {
const [envLockTitle, setEnvLockTitle] = useState<string>('正在检查 .env 锁定状态…') const [envLockTitle, setEnvLockTitle] = useState<string>('正在检查 .env 锁定状态…')
const prDiffSummaryRunningRef = useRef<Set<number>>(new Set()) const prDiffSummaryRunningRef = useRef<Set<number>>(new Set())
const [prDiffSummaryRunning, setPrDiffSummaryRunning] = useState<ReadonlySet<number>>(() => new Set()) const [prDiffSummaryRunning, setPrDiffSummaryRunning] = useState<ReadonlySet<number>>(() => new Set())
const [me, setMe] = useState<string | undefined>(undefined) const [me, setMe] = useState<string | undefined>(injected?.me)
const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null) const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null)
const handoffRunningRef = useRef<Set<number>>(new Set()) const handoffRunningRef = useRef<Set<number>>(new Set())
const [handoffRunning, setHandoffRunning] = useState<Set<number>>(new Set()) const [handoffRunning, setHandoffRunning] = useState<Set<number>>(new Set())
@@ -329,7 +364,7 @@ export function useIssues(): UseIssuesResult {
}, [scope]) }, [scope])
const refresh = useCallback((): void => { const refresh = useCallback((): void => {
setState({ status: 'loading' }) setState(prev => prev.status === 'ready' ? prev : { status: 'loading' })
postMessage({ type: 'issues/refresh' }) postMessage({ type: 'issues/refresh' })
// 用户刷新时重查发布状态:远端分支可能已经动了。 // 用户刷新时重查发布状态:远端分支可能已经动了。
postMessage({ type: 'release/check' }) postMessage({ type: 'release/check' })
@@ -637,7 +672,7 @@ export function useIssues(): UseIssuesResult {
const cleanup = onMessage((msg) => { const cleanup = onMessage((msg) => {
switch (msg.type) { switch (msg.type) {
case 'issues/loading': case 'issues/loading':
setState({ status: 'loading' }) setState(prev => prev.status === 'ready' ? prev : { status: 'loading' })
break break
case 'issues/update': case 'issues/update':
setState({ status: 'ready', issues: msg.issues }) setState({ status: 'ready', issues: msg.issues })