diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index abeca53..42e6486 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -13,7 +13,7 @@ import { Buffer } from 'node:buffer' import { logger } from '../logging/logger' -const PAGE_SIZE = 50 +export const PAGE_SIZE = 50 export interface GiteaUser { login: string @@ -226,6 +226,29 @@ export async function listAllRepoComments(opts: { return out } +/** + * 单页拉取某工单评论。Gitea 默认旧→新,末页才是最新 state JSON 所在。 + */ +export async function listIssueCommentsPage(opts: { + host: string + token: string + owner: string + repo: string + index: number + page: number +}): Promise { + 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 * `/repos/{owner}/{repo}/issues/{index}/comments`. Paginated until a short @@ -243,16 +266,8 @@ export async function listIssueComments(opts: { let page = 1 while (true) { - 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(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) + const batch = await listIssueCommentsPage({ ...opts, page }) + if (batch.length === 0) break out.push(...batch) if (batch.length < PAGE_SIZE) diff --git a/vscode/src/gitea/issueLoader.test.ts b/vscode/src/gitea/issueLoader.test.ts new file mode 100644 index 0000000..72fb06a --- /dev/null +++ b/vscode/src/gitea/issueLoader.test.ts @@ -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 { + 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() + }) +}) diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index 9eddd3a..3a0a368 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -4,17 +4,10 @@ * state-comment convention. * * Flow: - * 1. Kick off `/user` and the repo-wide comments firehose concurrently. - * Once `/user` resolves we know `login`, so fire both issue-filter - * queries (`assigned_by`, `created_by`) in parallel with the still- - * pending comments fetch. - * 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": "" }`. 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. + * 1. 拉工单列表(mine:先 /user 再 assigned+created;all:工单与 /user 并行)。 + * 2. 每张工单只从评论末页往前翻,直到碰到带 column 的 state JSON。 + * 不拉整仓库评论。缺 state 时内存用默认列,POST 不挡返回。 + * 3. 非 done 工单并行补依赖和 PR 状态。 */ import type { GiteaComment, GiteaIssue } from './api' @@ -27,9 +20,10 @@ import { getDependencies, getIssue, getPullRequest, - listAllRepoComments, listIssueComments, + listIssueCommentsPage, listIssuesByFilter, + PAGE_SIZE, postIssueComment, } from './api' @@ -219,16 +213,6 @@ function parseColumnFromComments(comments: GiteaComment[]): { 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. */ function mergeIssues(...lists: GiteaIssue[][]): GiteaIssue[] { const map = new Map() @@ -241,26 +225,49 @@ function mergeIssues(...lists: GiteaIssue[][]): GiteaIssue[] { return [...map.values()] } +const COMMENT_FETCH_CONCURRENCY = 8 + +async function mapPool(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out: R[] = Array.from({ length: items.length }) + let next = 0 + async function worker(): Promise { + 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. - * Comments whose `issue_url` cannot be parsed are dropped. + * 从末页往前拼评论,直到 parse 出 column 或翻到第 1 页。 + * commentCount=0 不发请求。 */ -function groupCommentsByIssue(comments: GiteaComment[]): Map { - const buckets = new Map() - for (const c of comments) { - const idx = indexFromIssueUrl(c.issue_url) - if (idx === undefined) - continue - const bucket = buckets.get(idx) - if (bucket) - bucket.push(c) - else - buckets.set(idx, [c]) +export async function listCommentsUntilColumn(opts: { + host: string + token: string + owner: string + repo: string + index: number + commentCount: number +}): Promise { + if (opts.commentCount <= 0) + return [] + 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()) { - bucket.sort((a, b) => a.created_at.localeCompare(b.created_at)) - } - return buckets + return comments } /** @@ -352,21 +359,17 @@ async function buildIssue(opts: { } else { column = defaultColumnForState(issue.state) - // Persist the default so future pulls are cheap; failures are - // non-fatal — we still display the issue in the computed column. - try { - await postIssueComment({ - host, - token, - owner, - repo, - index: issue.number, - body: JSON.stringify({ column }), - }) - } - catch (postErr) { + // 缺省列先用于本屏;POST 不挡返回,失败只记 warn。 + void postIssueComment({ + host, + token, + owner, + repo, + index: issue.number, + body: JSON.stringify({ column }), + }).catch((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 @@ -423,34 +426,44 @@ export async function loadIssues(opts: { workspaceRoot?: string /** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */ scope?: 'mine' | 'all' -}): Promise { +}): Promise<{ issues: Issue[], me: string }> { 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> - let allComments: Awaited + let me: string if (opts.scope === 'all') { - ;[merged, allComments] = await Promise.all([ + const [all, user] = await Promise.all([ listIssuesByFilter({ host, token, owner, repo }), - commentsPromise, + getCurrentUser({ host, token }), ]) + merged = all + me = user.login } 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 [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: 'created_by', user: user.login }), - commentsPromise, ]) merged = mergeIssues(assigned, created) - allComments = comments } 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() + for (let i = 0; i < merged.length; i++) + buckets.set(merged[i].number, commentLists[i]) // 先用各工单评论 bucket 算出 column,决定是否需要 per-issue 远程拉取。 // 完成(done)工单的依赖(前置阻塞)无意义、PR 状态永久不变(已合并), @@ -509,7 +522,7 @@ export async function loadIssues(opts: { })) } - return resolved + return { issues: resolved, me } } /** diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 8d6fd2d..2c14260 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -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() + /** webview 已发过 issues/refresh,此后才 postMessage,避免启动时消息丢。 */ + private webviewReady = false + private loadInFlight: Promise | undefined + private lastIssuesUpdate: Extract | 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 { + 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 { - 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 { 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(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 = { 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 { `\n `, ) + const snapshot = parseKanbanSnapshot(this.context.workspaceState.get(KANBAN_SNAPSHOT_KEY)) + if (snapshot) { + html = html.replace( + '', + ` ${snapshotScriptTag(nonce, snapshot)}\n `, + ) + } + return html } } diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index 1351214..ecbf282 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -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, diff --git a/vscode/src/panel/kanbanSnapshot.test.ts b/vscode/src/panel/kanbanSnapshot.test.ts new file mode 100644 index 0000000..1ddf12f --- /dev/null +++ b/vscode/src/panel/kanbanSnapshot.test.ts @@ -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: '', column: 'todo' as const, assignees: [], htmlUrl: 'https://x' }], + scope: 'mine' as const, + me: 'cruldra', + globalAutoReview: true, + youtrackConfigured: false, +} + +describe('kanbanSnapshot', () => { + it('往返:注入 JSON 不含原始 ,parse 还原标题', () => { + const json = snapshotToScriptJson(sample) + expect(json).not.toContain('') + expect(json).toContain('\\u003c') + const parsed = parseKanbanSnapshot(JSON.parse(json)) + expect(parsed?.issues[0].title).toBe('') + 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('') + }) + + it('垃圾数据返回 null', () => { + expect(parseKanbanSnapshot(null)).toBeNull() + expect(parseKanbanSnapshot({ scope: 'mine' })).toBeNull() + }) +}) diff --git a/vscode/src/panel/kanbanSnapshot.ts b/vscode/src/panel/kanbanSnapshot.ts new file mode 100644 index 0000000..659e33f --- /dev/null +++ b/vscode/src/panel/kanbanSnapshot.ts @@ -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 + 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(/${snapshotToScriptJson(snapshot)}` +} diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 3658c21..abcc81b 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -344,7 +344,7 @@ class WebhookCoordinator { const tok = await getToken(this.ctx!, remote.host) if (!tok) 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) if (found) { logger.add({ diff --git a/vscode/webview-ui/src/hooks/useIssues.ts b/vscode/webview-ui/src/hooks/useIssues.ts index a8d3905..41ffe45 100644 --- a/vscode/webview-ui/src/hooks/useIssues.ts +++ b/vscode/webview-ui/src/hooks/useIssues.ts @@ -2,8 +2,8 @@ * Subscribes to the extension host's issue stream. * * State machine (`UseIssuesState`): - * - loading : initial mount + any time the host pushes `issues/loading` - * - ready : host pushed `issues/update`; carries the current list + * - loading : 无快照的首次挂载,或 host 推 `issues/loading` 且当前还没有列表 + * - ready : host 推了 `issues/update`,或 HTML 里注入了上一屏快照 * - error : host pushed `issues/error`; carries the user-facing message * * Settings UI is a separate overlay (`settings: SettingsOverlayState | null`) @@ -287,13 +287,48 @@ export interface UseIssuesResult { 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 + 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 { - const [state, setState] = useState({ status: 'loading' }) + const injected = readInjectedSnapshot() + const [state, setState] = useState( + injected ? { status: 'ready', issues: injected.issues } : { status: 'loading' }, + ) const [settings, setSettings] = useState(null) const [youtrackProjects, setYoutrackProjects] = useState({ status: 'idle', projects: [] }) - const [globalAutoReview, setGlobalAutoReview] = useState(true) - const [scope, setScope] = useState<'mine' | 'all'>('mine') - const [youtrackConfigured, setYoutrackConfigured] = useState(false) + const [globalAutoReview, setGlobalAutoReview] = useState(injected?.globalAutoReview ?? true) + const [scope, setScope] = useState<'mine' | 'all'>(injected?.scope ?? 'mine') + const [youtrackConfigured, setYoutrackConfigured] = useState(injected?.youtrackConfigured ?? false) const [toasts, setToasts] = useState([]) const [profiles, setProfiles] = useState([]) const [logs, setLogs] = useState([]) @@ -307,7 +342,7 @@ export function useIssues(): UseIssuesResult { const [envLockTitle, setEnvLockTitle] = useState('正在检查 .env 锁定状态…') const prDiffSummaryRunningRef = useRef>(new Set()) const [prDiffSummaryRunning, setPrDiffSummaryRunning] = useState>(() => new Set()) - const [me, setMe] = useState(undefined) + const [me, setMe] = useState(injected?.me) const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null) const handoffRunningRef = useRef>(new Set()) const [handoffRunning, setHandoffRunning] = useState>(new Set()) @@ -329,7 +364,7 @@ export function useIssues(): UseIssuesResult { }, [scope]) const refresh = useCallback((): void => { - setState({ status: 'loading' }) + setState(prev => prev.status === 'ready' ? prev : { status: 'loading' }) postMessage({ type: 'issues/refresh' }) // 用户刷新时重查发布状态:远端分支可能已经动了。 postMessage({ type: 'release/check' }) @@ -637,7 +672,7 @@ export function useIssues(): UseIssuesResult { const cleanup = onMessage((msg) => { switch (msg.type) { case 'issues/loading': - setState({ status: 'loading' }) + setState(prev => prev.status === 'ready' ? prev : { status: 'loading' }) break case 'issues/update': setState({ status: 'ready', issues: msg.issues })