⚡ perf(vscode): 摘修复用本地合并记录分组,列表改手风琴
This commit is contained in:
1 parent
cfabee08b7
commit
5284fc1730
8 files changed
+183
-221
No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"publisher": "clurdra",
|
"publisher": "clurdra",
|
||||||
"name": "superpowers-vscode-clurdra",
|
"name": "superpowers-vscode-clurdra",
|
||||||
"displayName": "Superpowers-clurdra",
|
"displayName": "Superpowers-clurdra",
|
||||||
"version": "0.2.109",
|
"version": "0.2.110",
|
||||||
"packageManager": "pnpm@10.27.0",
|
"packageManager": "pnpm@10.27.0",
|
||||||
"description": "Superpowers specs and plans Kanban explorer",
|
"description": "Superpowers specs and plans Kanban explorer",
|
||||||
"author": "clurdra",
|
"author": "clurdra",
|
||||||
|
|||||||
@@ -82,7 +82,15 @@ export function pickCurrentRelease(prefix: string, remoteBranches: string[]): st
|
|||||||
return remoteBranches.filter(b => b.startsWith(prefix)).sort().at(-1)
|
return remoteBranches.filter(b => b.startsWith(prefix)).sort().at(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 同 PR 收一组(新→旧首次出现顺序)。pr 号来自 Gitea,不是扫提交说明。 */
|
/** Gitea 默认合并说明:Merge pull request '标题' (#260) from … */
|
||||||
|
export function parseGiteaMergeSubject(subject: string): { number: number, title: string } | undefined {
|
||||||
|
const m = /^Merge pull request '(.+)' \(#(\d+)\)/.exec(subject)
|
||||||
|
if (!m)
|
||||||
|
return undefined
|
||||||
|
return { title: m[1], number: Number(m[2]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同 PR 收一组(新→旧首次出现顺序)。pr 号来自合并记录,不是扫提交说明。 */
|
||||||
export function groupCommitsByPr(commits: UnreleasedCommit[]): ReleaseCommitGroup[] {
|
export function groupCommitsByPr(commits: UnreleasedCommit[]): ReleaseCommitGroup[] {
|
||||||
const prGroups = new Map<number, ReleaseCommitGroup>()
|
const prGroups = new Map<number, ReleaseCommitGroup>()
|
||||||
const prOrder: number[] = []
|
const prOrder: number[] = []
|
||||||
@@ -151,6 +159,56 @@ export async function listReleaseBranches(o: { cwd: string, prefix: string }): P
|
|||||||
return names.filter(b => b.startsWith(o.prefix)).sort().reverse()
|
return names.filter(b => b.startsWith(o.prefix)).sort().reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 用本地 merge 记录把未发布 sha 对上 PR。不打 Gitea。 */
|
||||||
|
export async function indexUnreleasedByMergePr(o: {
|
||||||
|
cwd: string
|
||||||
|
current: string
|
||||||
|
devBranch: string
|
||||||
|
shas: readonly string[]
|
||||||
|
}): Promise<Map<string, { number: number, title: string }>> {
|
||||||
|
const needed = new Set(o.shas)
|
||||||
|
const out = new Map<string, { number: number, title: string }>()
|
||||||
|
if (needed.size === 0)
|
||||||
|
return out
|
||||||
|
const raw = await mustGit(
|
||||||
|
o.cwd,
|
||||||
|
['log', '--right-only', '--cherry-pick', '--merges', '--format=%H%x00%s', unreleasedRange(o.current, o.devBranch)],
|
||||||
|
'读取合并记录失败',
|
||||||
|
)
|
||||||
|
if (!raw)
|
||||||
|
return out
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
const [sha, subject] = line.split('\0')
|
||||||
|
if (!sha || !subject)
|
||||||
|
continue
|
||||||
|
const parsed = parseGiteaMergeSubject(subject)
|
||||||
|
if (!parsed)
|
||||||
|
continue
|
||||||
|
const members = await mustGit(
|
||||||
|
o.cwd,
|
||||||
|
['rev-list', '--no-merges', `${sha}^1..${sha}^2`],
|
||||||
|
`读取 PR #${parsed.number} 提交失败`,
|
||||||
|
)
|
||||||
|
for (const member of members ? members.split('\n') : []) {
|
||||||
|
if (needed.has(member))
|
||||||
|
out.set(member, parsed)
|
||||||
|
}
|
||||||
|
if (out.size === needed.size)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachPulls<T extends { sha: string }>(
|
||||||
|
commits: T[],
|
||||||
|
index: Map<string, { number: number, title: string }>,
|
||||||
|
): Array<T & { pr?: number, prTitle?: string }> {
|
||||||
|
return commits.map((c) => {
|
||||||
|
const hit = index.get(c.sha)
|
||||||
|
return hit ? { ...c, pr: hit.number, prTitle: hit.title } : c
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, current: string }): Promise<UnreleasedCommit[]> {
|
export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, current: string }): Promise<UnreleasedCommit[]> {
|
||||||
const out = await mustGit(
|
const out = await mustGit(
|
||||||
o.cwd,
|
o.cwd,
|
||||||
|
|||||||
@@ -362,67 +362,6 @@ export async function getPullRequest(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listClosedPullsPage(opts: {
|
|
||||||
host: string
|
|
||||||
token: string
|
|
||||||
owner: string
|
|
||||||
repo: string
|
|
||||||
page: number
|
|
||||||
}): Promise<Array<{ number: number, title: string, merged: boolean, mergeCommitSha: string | null }>> {
|
|
||||||
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls`)
|
|
||||||
url.searchParams.set('state', 'closed')
|
|
||||||
url.searchParams.set('sort', 'recentupdate')
|
|
||||||
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 Array<{
|
|
||||||
number?: unknown
|
|
||||||
title?: unknown
|
|
||||||
merged?: unknown
|
|
||||||
merge_commit_sha?: unknown
|
|
||||||
}>
|
|
||||||
if (!Array.isArray(batch))
|
|
||||||
return []
|
|
||||||
return batch.map(p => ({
|
|
||||||
number: typeof p.number === 'number' ? p.number : Number(p.number),
|
|
||||||
title: typeof p.title === 'string' ? p.title : '',
|
|
||||||
merged: p.merged === true,
|
|
||||||
mergeCommitSha: typeof p.merge_commit_sha === 'string' && p.merge_commit_sha.length > 0
|
|
||||||
? p.merge_commit_sha
|
|
||||||
: null,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listPullRequestCommitShas(opts: {
|
|
||||||
host: string
|
|
||||||
token: string
|
|
||||||
owner: string
|
|
||||||
repo: string
|
|
||||||
index: number
|
|
||||||
}): Promise<string[]> {
|
|
||||||
const out: string[] = []
|
|
||||||
let page = 1
|
|
||||||
while (true) {
|
|
||||||
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/commits`)
|
|
||||||
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 Array<{ sha?: unknown }>
|
|
||||||
if (!Array.isArray(batch) || batch.length === 0)
|
|
||||||
break
|
|
||||||
for (const c of batch) {
|
|
||||||
if (typeof c.sha === 'string' && c.sha.length > 0)
|
|
||||||
out.push(c.sha)
|
|
||||||
}
|
|
||||||
if (batch.length < PAGE_SIZE)
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getIssue(opts: {
|
export async function getIssue(opts: {
|
||||||
host: string
|
host: string
|
||||||
token: string
|
token: string
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { indexCommitsByPull } from './commitPulls'
|
|
||||||
|
|
||||||
describe('indexCommitsByPull', () => {
|
|
||||||
it('用已合并 PR 的提交列表把 sha 对上 PR,不看 commit message', async () => {
|
|
||||||
const map = await indexCommitsByPull(
|
|
||||||
['3e519ea', '5572c4f', 'orphan'],
|
|
||||||
async (page) => {
|
|
||||||
if (page !== 1)
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
{ number: 260, title: '连续包月会员订阅体系', merged: true, mergeCommitSha: 'merge260' },
|
|
||||||
{ number: 99, title: '还没合', merged: false, mergeCommitSha: null },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
async (index) => {
|
|
||||||
if (index === 260)
|
|
||||||
return ['3e519ea', '5572c4f', 'other']
|
|
||||||
throw new Error(`should not fetch commits for PR ${index}`)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
expect(map.get('3e519ea')).toEqual({ number: 260, title: '连续包月会员订阅体系' })
|
|
||||||
expect(map.get('5572c4f')).toEqual({ number: 260, title: '连续包月会员订阅体系' })
|
|
||||||
expect(map.has('orphan')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('squash 合入时用 mergeCommitSha 对上', async () => {
|
|
||||||
const map = await indexCommitsByPull(
|
|
||||||
['squashsha'],
|
|
||||||
async () => [{ number: 8, title: 'squash pr', merged: true, mergeCommitSha: 'squashsha' }],
|
|
||||||
async () => ['original-on-branch'],
|
|
||||||
)
|
|
||||||
expect(map.get('squashsha')).toEqual({ number: 8, title: 'squash pr' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
export interface PullRef {
|
|
||||||
number: number
|
|
||||||
title: string
|
|
||||||
merged: boolean
|
|
||||||
mergeCommitSha: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ListPullsPage = (page: number) => Promise<PullRef[]>
|
|
||||||
export type ListPullCommits = (index: number) => Promise<string[]>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把未发布提交的 sha 对上已合并 PR。
|
|
||||||
* 只认 Gitea 的 PR 提交列表 / squash 的 mergeCommitSha,不扫 commit message。
|
|
||||||
*/
|
|
||||||
export async function indexCommitsByPull(
|
|
||||||
shas: readonly string[],
|
|
||||||
listPulls: ListPullsPage,
|
|
||||||
listCommits: ListPullCommits,
|
|
||||||
): Promise<Map<string, { number: number, title: string }>> {
|
|
||||||
const needed = new Set(shas)
|
|
||||||
const out = new Map<string, { number: number, title: string }>()
|
|
||||||
if (needed.size === 0)
|
|
||||||
return out
|
|
||||||
|
|
||||||
for (let page = 1; page <= 20; page++) {
|
|
||||||
const batch = await listPulls(page)
|
|
||||||
if (batch.length === 0)
|
|
||||||
break
|
|
||||||
for (const pr of batch) {
|
|
||||||
if (!pr.merged)
|
|
||||||
continue
|
|
||||||
const ref = { number: pr.number, title: pr.title }
|
|
||||||
if (pr.mergeCommitSha && needed.has(pr.mergeCommitSha))
|
|
||||||
out.set(pr.mergeCommitSha, ref)
|
|
||||||
const commits = await listCommits(pr.number)
|
|
||||||
for (const sha of commits) {
|
|
||||||
if (needed.has(sha))
|
|
||||||
out.set(sha, ref)
|
|
||||||
}
|
|
||||||
if (out.size === needed.size)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
if (batch.length < 50)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachPulls<T extends { sha: string }>(
|
|
||||||
commits: T[],
|
|
||||||
index: Map<string, { number: number, title: string }>,
|
|
||||||
): Array<T & { pr?: number, prTitle?: string }> {
|
|
||||||
return commits.map((c) => {
|
|
||||||
const hit = index.get(c.sha)
|
|
||||||
return hit ? { ...c, pr: hit.number, prTitle: hit.title } : c
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -7,16 +7,14 @@
|
|||||||
|
|
||||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||||
import { window, workspace } from 'vscode'
|
import { window, workspace } from 'vscode'
|
||||||
import { getToken } from '../../auth/secrets'
|
|
||||||
import { listClosedPullsPage, listPullRequestCommitShas } from '../../gitea/api'
|
|
||||||
import { attachPulls, indexCommitsByPull } from '../../gitea/commitPulls'
|
|
||||||
import { gitFetch } from '../../git/branches'
|
import { gitFetch } from '../../git/branches'
|
||||||
import { detectRepo } from '../../git/remote'
|
|
||||||
import {
|
import {
|
||||||
|
attachPulls,
|
||||||
cherryPickToRelease,
|
cherryPickToRelease,
|
||||||
cutRelease,
|
cutRelease,
|
||||||
getReleaseStatus,
|
getReleaseStatus,
|
||||||
groupCommitsByPr,
|
groupCommitsByPr,
|
||||||
|
indexUnreleasedByMergePr,
|
||||||
listReleaseBranches,
|
listReleaseBranches,
|
||||||
listUnreleasedCommits,
|
listUnreleasedCommits,
|
||||||
todayReleaseBranch,
|
todayReleaseBranch,
|
||||||
@@ -105,28 +103,17 @@ export async function handleReleaseCherryPick(panel: KanbanWebviewPanel): Promis
|
|||||||
toast(panel, 'info', '没有待摘的提交')
|
toast(panel, 'info', '没有待摘的提交')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let annotated = commits
|
const index = await indexUnreleasedByMergePr({
|
||||||
const spinner = toast(panel, 'info', '正在按 PR 分组…', { spinner: true, dismissOnTimer: 0 })
|
cwd: root,
|
||||||
try {
|
current,
|
||||||
const remote = await detectRepo(root)
|
devBranch: s.devBranch,
|
||||||
const token = remote ? await getToken(panel.context, remote.host) : undefined
|
shas: commits.map(c => c.sha),
|
||||||
if (remote && token) {
|
})
|
||||||
const gitea = { host: remote.host, token, owner: remote.owner, repo: remote.repo }
|
panel.postMessage({
|
||||||
const index = await indexCommitsByPull(
|
type: 'release/pick-show',
|
||||||
commits.map(c => c.sha),
|
branch: current,
|
||||||
page => listClosedPullsPage({ ...gitea, page }),
|
groups: groupCommitsByPr(attachPulls(commits, index)),
|
||||||
pr => listPullRequestCommitShas({ ...gitea, index: pr }),
|
})
|
||||||
)
|
|
||||||
annotated = attachPulls(commits, index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
toast(panel, 'error', '按 PR 分组失败,先按未关联列出', { dismissOnTimer: 5000 })
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
dismiss(panel, spinner)
|
|
||||||
}
|
|
||||||
panel.postMessage({ type: 'release/pick-show', branch: current, groups: groupCommitsByPr(annotated) })
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
toast(panel, 'error', err instanceof Error ? err.message : String(err), { dismissOnTimer: 10_000 })
|
toast(panel, 'error', err instanceof Error ? err.message : String(err), { dismissOnTimer: 10_000 })
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
pickCurrentRelease,
|
pickCurrentRelease,
|
||||||
todayReleaseBranch,
|
todayReleaseBranch,
|
||||||
groupCommitsByPr,
|
groupCommitsByPr,
|
||||||
|
parseGiteaMergeSubject,
|
||||||
|
indexUnreleasedByMergePr,
|
||||||
} from '../../src/git/release'
|
} from '../../src/git/release'
|
||||||
|
|
||||||
const gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }
|
const gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }
|
||||||
@@ -57,6 +59,13 @@ describe('纯函数', () => {
|
|||||||
expect(pickCurrentRelease('release/', ['main', 'release/2026-08-01', 'release/2026-08-31', 'hotfix/2027'])).toBe('release/2026-08-31')
|
expect(pickCurrentRelease('release/', ['main', 'release/2026-08-01', 'release/2026-08-31', 'hotfix/2027'])).toBe('release/2026-08-31')
|
||||||
expect(pickCurrentRelease('release/', ['main'])).toBeUndefined()
|
expect(pickCurrentRelease('release/', ['main'])).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
it('parseGiteaMergeSubject 认 Gitea 合并说明', () => {
|
||||||
|
expect(parseGiteaMergeSubject("Merge pull request '连续包月会员订阅体系' (#260) from feature/monthly-membership into main")).toEqual({
|
||||||
|
number: 260,
|
||||||
|
title: '连续包月会员订阅体系',
|
||||||
|
})
|
||||||
|
expect(parseGiteaMergeSubject('fix: no merge')).toBeUndefined()
|
||||||
|
})
|
||||||
it('groupCommitsByPr 按 Gitea 给的 pr 号分组,说明里没有 Closes 也能归组', () => {
|
it('groupCommitsByPr 按 Gitea 给的 pr 号分组,说明里没有 Closes 也能归组', () => {
|
||||||
const groups = groupCommitsByPr([
|
const groups = groupCommitsByPr([
|
||||||
{ sha: '3e519ea', subject: 'fix(admin): 会员档位隐藏', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' },
|
{ sha: '3e519ea', subject: 'fix(admin): 会员档位隐藏', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' },
|
||||||
@@ -133,4 +142,23 @@ describe('集成', () => {
|
|||||||
git(fx.repo, ['fetch', 'origin'])
|
git(fx.repo, ['fetch', 'origin'])
|
||||||
expect(await listReleaseBranches({ cwd: fx.repo, prefix: 'release/' })).toEqual(['release/2026-08-31', 'release/2026-08-01'])
|
expect(await listReleaseBranches({ cwd: fx.repo, prefix: 'release/' })).toEqual(['release/2026-08-31', 'release/2026-08-01'])
|
||||||
})
|
})
|
||||||
|
it('indexUnreleasedByMergePr 用本地合并记录把分支提交对上 PR', async () => {
|
||||||
|
fx = await setup()
|
||||||
|
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })
|
||||||
|
git(fx.peer, ['checkout', '-b', 'feature'])
|
||||||
|
const a = commit(fx.peer, 'b.txt', 'b', 'fix(admin): 会员档位隐藏')
|
||||||
|
const b = commit(fx.peer, 'c.txt', 'c', 'fix(membership): 年付月度发卡')
|
||||||
|
git(fx.peer, ['checkout', 'main'])
|
||||||
|
git(fx.peer, ['merge', '--no-ff', '-m', "Merge pull request '连续包月会员订阅体系' (#260) from feature into main", 'feature'])
|
||||||
|
git(fx.peer, ['push', 'origin', 'main'])
|
||||||
|
git(fx.repo, ['fetch', 'origin'])
|
||||||
|
const map = await indexUnreleasedByMergePr({
|
||||||
|
cwd: fx.repo,
|
||||||
|
current: 'release/2026-08-31',
|
||||||
|
devBranch: 'main',
|
||||||
|
shas: [a, b],
|
||||||
|
})
|
||||||
|
expect(map.get(a)).toEqual({ number: 260, title: '连续包月会员订阅体系' })
|
||||||
|
expect(map.get(b)).toEqual({ number: 260, title: '连续包月会员订阅体系' })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReleaseCommitGroup } from '../lib/messages'
|
import type { ReleaseCommitGroup } from '../lib/messages'
|
||||||
import { X } from 'lucide-react'
|
import { ChevronRight, X } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,6 +10,10 @@ interface Props {
|
|||||||
onConfirm: (shas: string[]) => void
|
onConfirm: (shas: string[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupKey(g: ReleaseCommitGroup): string {
|
||||||
|
return g.pr != null ? `pr-${g.pr}` : 'ungrouped'
|
||||||
|
}
|
||||||
|
|
||||||
function filterGroups(groups: ReleaseCommitGroup[], query: string): ReleaseCommitGroup[] {
|
function filterGroups(groups: ReleaseCommitGroup[], query: string): ReleaseCommitGroup[] {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
if (!q)
|
if (!q)
|
||||||
@@ -33,12 +37,14 @@ function filterGroups(groups: ReleaseCommitGroup[], query: string): ReleaseCommi
|
|||||||
export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: Props) {
|
export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: Props) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open)
|
if (!open)
|
||||||
return
|
return
|
||||||
setQuery('')
|
setQuery('')
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
|
setExpanded(new Set(groups.filter(g => g.pr == null).map(groupKey)))
|
||||||
}, [open, groups])
|
}, [open, groups])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,7 +74,7 @@ export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: P
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGroup(g: ReleaseCommitGroup): void {
|
function toggleGroupSelected(g: ReleaseCommitGroup): void {
|
||||||
const shas = g.commits.map(c => c.sha)
|
const shas = g.commits.map(c => c.sha)
|
||||||
const allOn = shas.every(s => selected.has(s))
|
const allOn = shas.every(s => selected.has(s))
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
@@ -83,6 +89,18 @@ export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: P
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(g: ReleaseCommitGroup): void {
|
||||||
|
const key = groupKey(g)
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key))
|
||||||
|
next.delete(key)
|
||||||
|
else
|
||||||
|
next.add(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const count = selected.size
|
const count = selected.size
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -112,45 +130,69 @@ export function CherryPickModal({ open, branch, groups, onCancel, onConfirm }: P
|
|||||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 py-3">
|
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 py-3">
|
||||||
{visible.length === 0
|
{visible.length === 0
|
||||||
? <div className="py-8 text-center text-xs opacity-70">没有匹配的提交</div>
|
? <div className="py-8 text-center text-xs opacity-70">没有匹配的提交</div>
|
||||||
: visible.map(g => (
|
: visible.map((g) => {
|
||||||
<section
|
const key = groupKey(g)
|
||||||
key={g.pr ?? `ungrouped-${g.commits[0]?.sha}`}
|
const openGroup = query.trim() !== '' || expanded.has(key)
|
||||||
className="rounded border border-[var(--vscode-panel-border)]"
|
const shas = g.commits.map(c => c.sha)
|
||||||
>
|
const allOn = shas.length > 0 && shas.every(s => selected.has(s))
|
||||||
<button
|
const someOn = shas.some(s => selected.has(s))
|
||||||
type="button"
|
return (
|
||||||
onClick={() => toggleGroup(g)}
|
<section
|
||||||
className="flex w-full items-center gap-2 border-b border-[var(--vscode-panel-border)] px-3 py-2 text-left hover:bg-white/5"
|
key={key}
|
||||||
|
className="rounded border border-[var(--vscode-panel-border)]"
|
||||||
>
|
>
|
||||||
<span className="flex-1 truncate font-medium">
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
{g.pr != null ? `#${g.pr} ${g.title}` : g.title}
|
<button
|
||||||
</span>
|
type="button"
|
||||||
<span className="shrink-0 text-[11px] opacity-60">
|
aria-expanded={openGroup}
|
||||||
{`${g.authors.join(' · ')} · ${g.commits.length} 条`}
|
onClick={() => toggleExpanded(g)}
|
||||||
</span>
|
className="flex min-w-0 flex-1 items-center gap-2 text-left hover:opacity-90"
|
||||||
</button>
|
>
|
||||||
<ul className="py-1">
|
<ChevronRight className={`size-3.5 shrink-0 opacity-70 transition-transform ${openGroup ? 'rotate-90' : ''}`} />
|
||||||
{g.commits.map(c => (
|
<span className="min-w-0 flex-1 truncate font-medium">
|
||||||
<li key={c.sha}>
|
{g.pr != null ? `#${g.pr} ${g.title}` : g.title}
|
||||||
<label className="flex cursor-pointer items-start gap-2 px-3 py-1.5 hover:bg-white/5">
|
</span>
|
||||||
<input
|
<span className="shrink-0 text-[11px] opacity-60">
|
||||||
type="checkbox"
|
{`${g.authors.join(' · ')} · ${g.commits.length} 条`}
|
||||||
checked={selected.has(c.sha)}
|
</span>
|
||||||
onChange={() => toggleSha(c.sha)}
|
</button>
|
||||||
className="mt-0.5"
|
<input
|
||||||
/>
|
type="checkbox"
|
||||||
<span className="min-w-0 flex-1">
|
checked={allOn}
|
||||||
<span className="block truncate">{c.subject}</span>
|
ref={(el) => {
|
||||||
<span className="block text-[11px] opacity-60">
|
if (el)
|
||||||
{`${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`}
|
el.indeterminate = someOn && !allOn
|
||||||
</span>
|
}}
|
||||||
</span>
|
onChange={() => toggleGroupSelected(g)}
|
||||||
</label>
|
aria-label="全选本组"
|
||||||
</li>
|
className="shrink-0"
|
||||||
))}
|
/>
|
||||||
</ul>
|
</div>
|
||||||
</section>
|
{openGroup && (
|
||||||
))}
|
<ul className="border-t border-[var(--vscode-panel-border)] py-1">
|
||||||
|
{g.commits.map(c => (
|
||||||
|
<li key={c.sha}>
|
||||||
|
<label className="flex cursor-pointer items-start gap-2 px-3 py-1.5 hover:bg-white/5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(c.sha)}
|
||||||
|
onChange={() => toggleSha(c.sha)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate">{c.subject}</span>
|
||||||
|
<span className="block text-[11px] opacity-60">
|
||||||
|
{`${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--vscode-panel-border)] px-4 py-3">
|
<div className="flex items-center justify-end gap-2 border-t border-[var(--vscode-panel-border)] px-4 py-3">
|
||||||
<span className="mr-auto text-xs opacity-70">
|
<span className="mr-auto text-xs opacity-70">
|
||||||
|
|||||||
Reference in new issue
Block a user