⚡ perf(vscode): 看板打开先出快照、评论只拉末页
This commit is contained in:
1 parent
904ab7491d
commit
a68caf5f6e
9 files changed
+447
-115
No files matched your search
+26
-11
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>`
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
Reference in new issue
Block a user