🐛 fix(vscode): GitHub 请求和 gh auth token 加超时,设置页连不上 GitHub 时不再显示成未登录

This commit is contained in:
cruldra committed 2026-10-19 08:55:49 +11:00
1 parent 9c7c9d8636
commit a8d922f3ba
16 files changed
+258 -35

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import type { ForgeContext } from '../forge/context'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ForgeApiError } from '../forge/types'
vi.mock('vscode', () => ({ StatusBarAlignment: {}, ThemeColor: class {}, window: {} }))
vi.mock('../forge/context', () => ({ resolveForge: vi.fn() }))
vi.mock('../logging/logger', () => ({ logger: { add: vi.fn() } }))
function forge(getCurrentUser: () => Promise<unknown>): ForgeContext {
return { workspaceRoot: '/repo', kind: 'github', host: 'github.com', owner: 'o', repo: 'r', provider: { getCurrentUser } } as never
}
describe('lookupLogin', () => {
beforeEach(async () => {
const { clearIdentityCache } = await import('./identity.js')
clearIdentityCache()
})
it('解析出 login 后按 host 缓存,下次不再请求', async () => {
const { lookupLogin } = await import('./identity.js')
const getCurrentUser = vi.fn().mockResolvedValue({ login: 'cruldra' })
expect(await lookupLogin(forge(getCurrentUser))).toEqual({ ok: true, login: 'cruldra' })
expect(await lookupLogin(forge(getCurrentUser))).toEqual({ ok: true, login: 'cruldra' })
expect(getCurrentUser).toHaveBeenCalledTimes(1)
})
it('失败时把错误交给调用方,好区分登录失效和连不上;loginFor 照旧只给 undefined', async () => {
const { loginFor, lookupLogin } = await import('./identity.js')
const err = new ForgeApiError(408, '请求超时')
expect(await lookupLogin(forge(() => Promise.reject(err)))).toEqual({ ok: false, error: err })
expect(await loginFor(forge(() => Promise.reject(err)))).toBeUndefined()
})
})
+11 -3
View File
@@ -25,13 +25,21 @@ let statusBar: StatusBarItem | undefined
* 解析并缓存 `forge` 当前凭据对应的 login。失败(网络、凭据失效)返回 `undefined`,由调用方决定降级:门禁侧的语义是「身份未知 → 只观察」。
*/
export async function loginFor(forge: ForgeContext): Promise<string | undefined> {
const found = await lookupLogin(forge)
return found.ok ? found.login : undefined
}
/**
* 同 [loginFor],失败时把错误交给调用方,好区分凭据失效(401)和网络不通、超时。
*/
export async function lookupLogin(forge: ForgeContext): Promise<{ ok: true, login: string } | { ok: false, error: unknown }> {
const hit = userByHost.get(forge.host)
if (hit)
return hit.login
return { ok: true, login: hit.login }
try {
const user = await forge.provider.getCurrentUser()
userByHost.set(forge.host, user)
return user.login
return { ok: true, login: user.login }
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -41,7 +49,7 @@ export async function loginFor(forge: ForgeContext): Promise<string | undefined>
message: `解析身份失败 host=${forge.host}`,
details: message,
})
return undefined
return { ok: false, error: err }
}
}
+13 -2
View File
@@ -56,7 +56,7 @@ describe('resolveForge', () => {
it('gitHub 仓库用 gh 的登录凭据造出 provider,不读插件里存的 token', async () => {
const { resolveForge } = await import('./context.js')
mocks.detectRepo.mockResolvedValue({ host: 'github.com', owner: 'o', repo: 'r' })
mocks.readGhToken.mockResolvedValue('gho_x')
mocks.readGhToken.mockResolvedValue({ ok: true, token: 'gho_x' })
const resolved = await resolveForge(ctx)
@@ -71,7 +71,8 @@ describe('resolveForge', () => {
it('gh 没登录时是 no-credentials,提示去终端运行 gh auth login', async () => {
const { resolveForge } = await import('./context.js')
mocks.detectRepo.mockResolvedValue({ host: 'github.com', owner: 'o', repo: 'r' })
mocks.readGhToken.mockResolvedValue(undefined)
const { GH_LOGIN_HINT } = await import('./github/ghAuth.js')
mocks.readGhToken.mockResolvedValue({ ok: false, message: GH_LOGIN_HINT })
const resolved = await resolveForge(ctx)
@@ -80,6 +81,16 @@ describe('resolveForge', () => {
expect(mocks.getToken).not.toHaveBeenCalled()
})
it('gh 取 token 超时也算 no-credentials,但提示照 gh 给的原因说是超时', async () => {
const { resolveForge } = await import('./context.js')
mocks.detectRepo.mockResolvedValue({ host: 'github.com', owner: 'o', repo: 'r' })
mocks.readGhToken.mockResolvedValue({ ok: false, message: '`gh auth token` 超时' })
const resolved = await resolveForge(ctx)
expect(resolved).toMatchObject({ ok: false, reason: 'no-credentials', kind: 'github', host: 'github.com', message: '`gh auth token` 超时' })
})
it('各种失败的提示都不带 Gitea 字样', async () => {
const { resolveForge } = await import('./context.js')
+5 -5
View File
@@ -11,7 +11,7 @@ import { workspace } from 'vscode'
import { getToken } from '../auth/secrets'
import { detectRepo } from '../git/remote'
import { GiteaProvider } from './gitea/provider'
import { GH_LOGIN_HINT, readGhToken } from './github/ghAuth'
import { readGhToken } from './github/ghAuth'
import { GitHubProvider } from './github/provider'
import { forgeKindForHost } from './types'
@@ -43,10 +43,10 @@ export async function openForge(ctx: ExtensionContext, workspaceRoot: string, re
const kind = forgeKindForHost(remote.host)
const repo = { workspaceRoot, kind, host: remote.host, owner: remote.owner, repo: remote.repo }
if (kind === 'github') {
const token = await readGhToken()
if (!token)
return { ok: false, reason: 'no-credentials', kind, host: remote.host, message: GH_LOGIN_HINT }
return { ok: true, forge: { ...repo, provider: new GitHubProvider({ owner: remote.owner, repo: remote.repo, token }) } }
const gh = await readGhToken()
if (!gh.ok)
return { ok: false, reason: 'no-credentials', kind, host: remote.host, message: gh.message }
return { ok: true, forge: { ...repo, provider: new GitHubProvider({ owner: remote.owner, repo: remote.repo, token: gh.token }) } }
}
const token = await getToken(ctx, remote.host)
if (!token)
+37 -4
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ForgeApiError } from '../types'
type ExecCallback = (err: Error | null, stdout: string, stderr: string) => void
@@ -22,21 +23,53 @@ describe('readGhToken', () => {
const { readGhToken } = await import('./ghAuth.js')
execResult(null, ' gho_abc\n')
expect(await readGhToken()).toBe('gho_abc')
expect(await readGhToken()).toEqual({ ok: true, token: 'gho_abc' })
const [file, args, opts] = mocks.execFile.mock.calls[0] as [string, string[], Record<string, unknown>]
expect(file).toMatch(/(?:^|\/)gh$/)
expect(args).toEqual(['auth', 'token', '--hostname', 'github.com'])
expect(opts.shell).toBeUndefined()
expect(opts.timeout).toBe(10_000)
})
it.each([
['gh 不存在', Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }), ''],
['退出码非 0(没登录)', Object.assign(new Error('exit 1'), { code: 1 }), ''],
['输出为空', null, ' \n'],
])('%s → undefined', async (_name, err, stdout) => {
const { readGhToken } = await import('./ghAuth.js')
])('%s → 没登录', async (_name, err, stdout) => {
const { GH_LOGIN_HINT, readGhToken } = await import('./ghAuth.js')
execResult(err, stdout)
expect(await readGhToken()).toBeUndefined()
expect(await readGhToken()).toEqual({ ok: false, message: GH_LOGIN_HINT })
})
it('10 秒没返回被结束 → 同样取不到 token,但提示说的是超时而不是没登录', async () => {
const { GH_LOGIN_HINT, readGhToken } = await import('./ghAuth.js')
execResult(Object.assign(new Error('Command failed: gh auth token'), { killed: true, signal: 'SIGTERM', code: null }))
const result = await readGhToken()
expect(result.ok).toBe(false)
const message = result.ok ? '' : result.message
expect(message).not.toBe(GH_LOGIN_HINT)
expect(message).toContain('超时')
})
})
describe('ghLoginCheckError', () => {
it('gitHub 回 401 是登录失效,不给说明,设置页按未登录显示', async () => {
const { ghLoginCheckError } = await import('./ghAuth.js')
expect(ghLoginCheckError(new ForgeApiError(401, 'Bad credentials'))).toBeUndefined()
})
it.each([
['请求超时', new ForgeApiError(408, '请求 api.github.com 超时(15 秒内没有响应)'), '超时'],
['连不上', new TypeError('fetch failed'), 'fetch failed'],
['GitHub 出错', new ForgeApiError(502, 'Bad Gateway'), 'Bad Gateway'],
])('%s → 无法连接 GitHub,带上原因', async (_name, err, reason) => {
const { ghLoginCheckError } = await import('./ghAuth.js')
const message = ghLoginCheckError(err)
expect(message).toContain('无法连接 GitHub')
expect(message).toContain(reason)
expect(message).not.toContain('未登录')
})
})
+35 -4
View File
@@ -4,6 +4,10 @@
import { execFile } from 'node:child_process'
import { findExecutable } from '../../shared/executable'
import { ForgeApiError } from '../types'
/** 等 `gh auth token` 返回的最长时间;gh 卡在钥匙串等地方时不能让看板一直等下去。 */
const GH_TOKEN_TIMEOUT_MS = 10_000
/** 没取到 gh 登录凭据时给人看的提示。 */
export const GH_LOGIN_HINT = '未登录 GitHub CLI,请在终端运行 `gh auth login` 登录后重试'
@@ -11,16 +15,43 @@ export const GH_LOGIN_HINT = '未登录 GitHub CLI,请在终端运行 `gh auth
/** gh 登录已失效(GitHub 回 401)时给人看的提示。 */
export const GH_RELOGIN_HINT = 'GitHub CLI 的登录已失效,请在终端运行 `gh auth login` 重新登录后重试'
/** `gh auth token` 超时时给人看的提示,和没登录分开说,免得用户白白重新登录。 */
const GH_TIMEOUT_HINT = `\`gh auth token\` 超时(${GH_TOKEN_TIMEOUT_MS / 1000} 秒内没有返回),取不到 GitHub 凭据;请在终端运行 \`gh auth status\` 看看 gh 是否卡住,然后重试`
/** PATH 里找不到 gh 时再试的安装位置:Homebrew(Apple 芯片、Intel)和系统包管理器。 */
const GH_FALLBACKS = ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh']
/** 取 gh 在 github.com 上登录的 token;gh 没装、没登录、执行失败或输出为空都返回 `undefined`。 */
export function readGhToken(): Promise<string | undefined> {
/** [readGhToken] 的结果:取到了给 `token`,取不到给可以直接给人看的 `message`。 */
export type GhToken = { ok: true, token: string } | { ok: false, message: string }
/**
* 取 gh 在 github.com 上登录的 token。
*
* gh 没装、没登录、执行失败或输出为空,`message` 是 [GH_LOGIN_HINT];超时被结束时是超时的说明。
*/
export function readGhToken(): Promise<GhToken> {
const gh = findExecutable('gh', GH_FALLBACKS) ?? 'gh'
return new Promise((resolve) => {
execFile(gh, ['auth', 'token', '--hostname', 'github.com'], { timeout: 10_000, encoding: 'utf8' }, (err, stdout) => {
execFile(gh, ['auth', 'token', '--hostname', 'github.com'], { timeout: GH_TOKEN_TIMEOUT_MS, encoding: 'utf8' }, (err, stdout) => {
// `killed` 只在超时被 execFile 结束时为真。
if (err?.killed) {
resolve({ ok: false, message: GH_TIMEOUT_HINT })
return
}
const token = err ? '' : String(stdout).trim()
resolve(token || undefined)
resolve(token ? { ok: true, token } : { ok: false, message: GH_LOGIN_HINT })
})
})
}
/**
* 设置页查 gh 账号失败时显示的说明;GitHub 回 401 说明登录失效,返回 `undefined`,让设置页按未登录显示。
*
* 其它失败(超时、连不上、GitHub 自己出错)都说成连不上,这时登录可能还有效,不能叫用户重新登录。
*/
export function ghLoginCheckError(err: unknown): string | undefined {
if (err instanceof ForgeApiError && err.status === 401)
return undefined
const reason = err instanceof Error ? err.message : String(err)
return `无法连接 GitHub,暂时确认不了登录状态:${reason}`
}
+51
View File
@@ -72,6 +72,57 @@ describe('请求头', () => {
})
})
describe('超时', () => {
afterEach(() => vi.restoreAllMocks())
const timeout = (): DOMException => new DOMException('The operation was aborted due to timeout', 'TimeoutError')
it('rEST、GraphQL 和取原文的请求都带 15 秒超时', async () => {
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout')
const fetch = stubFetch(json({ login: 'cruldra' }), json(rawIssue(5)), json({ data: {} }), new Response('原文'))
const p = provider()
await p.getCurrentUser()
await p.deleteIssue(5)
await p.getRawFile('a.ts', 'main')
expect(fetch).toHaveBeenCalledTimes(4)
for (let i = 0; i < 4; i++)
expect(calledInit(fetch, i).signal).toBeInstanceOf(AbortSignal)
expect(timeoutSpy.mock.calls.map(c => c[0])).toEqual([15_000, 15_000, 15_000, 15_000])
})
it('等响应超时抛中性的 ForgeApiError:不是 401,说明里有超时', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeout()))
const err = await provider().getCurrentUser().then(() => undefined, (e: unknown) => e)
expect(err).toBeInstanceOf(ForgeApiError)
expect(err).toMatchObject({ status: 408 })
expect((err as Error).message).toContain('超时')
})
it('读响应体时超时也一样', async () => {
stubFetch(new Response(new ReadableStream({ start: c => c.error(timeout()) })))
const err = await provider().getCurrentUser().then(() => undefined, (e: unknown) => e)
expect(err).toBeInstanceOf(ForgeApiError)
expect(err).toMatchObject({ status: 408 })
})
it('连不上等其它网络错误原样抛出', async () => {
const cause = new TypeError('fetch failed')
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(cause))
await expect(provider().getCurrentUser()).rejects.toBe(cause)
})
it('取原文超时照旧返回空串', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeout()))
expect(await provider().getRawFile('a.ts', 'main')).toBe('')
})
})
describe('翻页', () => {
it('每页 100 条,跟着 Link 的 rel="next" 原样请求下一页', async () => {
const next = `${BASE}/issues?state=all&per_page=100&page=2`
+34 -5
View File
@@ -28,6 +28,8 @@ const API_HOST = 'api.github.com'
const API_VERSION = '2022-11-28'
/** GitHub 列表接口每页上限。 */
const PAGE_SIZE = 100
/** 一次请求从发出到读完响应最多等多久,网络卡住时看板不能一直转圈。 */
const REQUEST_TIMEOUT_MS = 15_000
const AVATAR_HOST = 'avatars.githubusercontent.com'
/** 卡片上的头像只有十几像素,要 64 像素的图在高分屏上也够清楚。 */
const AVATAR_SIZE = 64
@@ -151,6 +153,23 @@ function errorText(body: string): string {
return body
}
/** 请求超时换成中性的 [ForgeApiError](`status` 记 408),不会被当成登录失效;其它错误原样返回。 */
function timeoutAsApiError(err: unknown): unknown {
if (err instanceof DOMException && err.name === 'TimeoutError')
return new ForgeApiError(408, `请求 ${API_HOST} 超时(${REQUEST_TIMEOUT_MS / 1000} 秒内没有响应)`)
return err
}
/** 读响应体里的 JSON;读到一半超时同样换成 [ForgeApiError]。 */
async function readJson<T>(res: Response): Promise<T> {
try {
return await res.json() as T
}
catch (err) {
throw timeoutAsApiError(err)
}
}
export class GitHubProvider implements ForgeProvider {
readonly kind = 'github' as const
readonly capabilities = CAPABILITIES
@@ -178,9 +197,19 @@ export class GitHubProvider implements ForgeProvider {
}
}
/** 发请求并限时 [REQUEST_TIMEOUT_MS],计时一直覆盖到读完响应体;连不上等网络错误原样抛出。 */
private async request(url: string, init: RequestInit): Promise<Response> {
try {
return await fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
}
catch (err) {
throw timeoutAsApiError(err)
}
}
private async send(url: string, init: { method?: string, body?: unknown } = {}): Promise<Response> {
const hasBody = init.body !== undefined
const res = await fetch(url, {
const res = await this.request(url, {
...(init.method ? { method: init.method } : {}),
headers: this.headers({ json: hasBody }),
...(hasBody ? { body: JSON.stringify(init.body) } : {}),
@@ -193,7 +222,7 @@ export class GitHubProvider implements ForgeProvider {
}
private async getJson<T>(url: string): Promise<T> {
return await (await this.send(url)).json() as T
return readJson<T>(await this.send(url))
}
/**
@@ -209,7 +238,7 @@ export class GitHubProvider implements ForgeProvider {
const out: T[] = []
for (;;) {
const res = await this.send(url.toString())
const batch = await res.json() as T[]
const batch = await readJson<T[]>(res)
if (!Array.isArray(batch))
return out
out.push(...batch)
@@ -287,7 +316,7 @@ export class GitHubProvider implements ForgeProvider {
},
})
// GraphQL 出错时 HTTP 状态照样是 200,错误放在 errors 里。
const { errors } = await res.json() as { errors?: Array<{ type?: string, message?: string }> }
const { errors } = await readJson<{ errors?: Array<{ type?: string, message?: string }> }>(res)
if (!errors?.length)
return
if (errors.some(e => e.type === 'FORBIDDEN'))
@@ -388,7 +417,7 @@ export class GitHubProvider implements ForgeProvider {
const url = new URL(`${this.repoUrl}/contents/${encodePath(path)}`)
url.searchParams.set('ref', ref)
try {
const res = await fetch(url.toString(), { headers: this.headers({ accept: 'application/vnd.github.raw+json' }) })
const res = await this.request(url.toString(), { headers: this.headers({ accept: 'application/vnd.github.raw+json' }) })
return res.ok ? await res.text() : ''
}
catch {
+1 -1
View File
@@ -111,7 +111,7 @@ export interface ForgeCapabilities {
mergeMethods: readonly MergeMethod[]
}
/** 代码托管接口返回非 2xx 时抛出,`status` 是 HTTP 状态码。 */
/** 代码托管接口返回非 2xx 时抛出,`status` 是 HTTP 状态码;请求超时也用它,`status` 记 408。 */
export class ForgeApiError extends Error {
readonly status: number
+15 -9
View File
@@ -7,10 +7,11 @@ import * as os from 'node:os'
import * as path from 'node:path'
import process from 'node:process'
import { commands, env, RelativePattern, Uri, workspace } from 'vscode'
import { clearIdentityCache, initIdentity, loginFor } from '../../auth/identity'
import { clearIdentityCache, initIdentity, lookupLogin } from '../../auth/identity'
import { getToken, getYouTrackToken, setToken, setYouTrackToken } from '../../auth/secrets'
import { listClaudeProfiles, setProfilesDirOverride } from '../../cc/profiles'
import { resolveForge } from '../../forge/context'
import { GH_LOGIN_HINT, ghLoginCheckError } from '../../forge/github/ghAuth'
import { forgeKindForHost } from '../../forge/types'
import { detectRepo } from '../../git/remote'
import { logger } from '../../logging/logger'
@@ -257,7 +258,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
const forgeKind = forgeKindForHost(host)
const tok = forgeKind === 'gitea' && host ? await getToken(panel.context, host) : undefined
const tokenSaved = !!tok && tok.length > 0
const ghLogin = forgeKind === 'github' ? await currentGhLogin(panel) : undefined
const gh = forgeKind === 'github' ? await currentGhStatus(panel) : {}
const ytTok = s.youtrackBaseUrl.trim()
? await getYouTrackToken(panel.context, youtrackHost(s.youtrackBaseUrl.trim()))
: undefined
@@ -269,7 +270,8 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
type: 'settings/show',
host,
forgeKind,
...(ghLogin ? { ghLogin } : {}),
...(gh.login ? { ghLogin: gh.login } : {}),
...(gh.error ? { ghLoginError: gh.error } : {}),
canCancel: true,
tokenSaved,
webhookPort: s.webhookPort,
@@ -312,18 +314,22 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
}
/**
* 现查 gh 登录的 GitHub 账号,没登录或登录失效返回 `undefined`。
* 现查 gh 登录的 GitHub 账号:查到了给 `login`;gh 没登录或登录已失效时什么都不给,设置页按未登录显示。
*
* 用户可能刚在终端登录或换了账号,所以先丢掉身份缓存再查,顺带刷新状态栏。
* gh 取 token 超时、连不上 GitHub 时给 `error`,这时登录可能还有效,不能说成没登录。用户可能刚在终端登录或换了账号,
* 所以先丢掉身份缓存再查,顺带刷新状态栏。
*/
async function currentGhLogin(panel: KanbanWebviewPanel): Promise<string | undefined> {
async function currentGhStatus(panel: KanbanWebviewPanel): Promise<{ login?: string, error?: string }> {
const resolved = await resolveForge(panel.context)
if (!resolved.ok)
return undefined
return resolved.message === GH_LOGIN_HINT ? {} : { error: resolved.message }
clearIdentityCache(resolved.forge.host)
const login = await loginFor(resolved.forge)
const found = await lookupLogin(resolved.forge)
void initIdentity(panel.context)
return login
if (found.ok)
return { login: found.login }
const error = ghLoginCheckError(found.error)
return error ? { error } : {}
}
/** Forces the open panel (if any) into the setup-auth state. */
+2
View File
@@ -73,6 +73,8 @@ export type ExtensionToWebview
forgeKind: ForgeKind
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。 */
ghLogin?: string
/** 查 gh 账号时连不上 GitHub、超时这类失败的说明;有它时不能按没登录显示。 */
ghLoginError?: string
errorMessage?: string
canCancel?: boolean
tokenSaved: boolean
+1
View File
@@ -580,6 +580,7 @@ export function App() {
host={settings?.host ?? ''}
forgeKind={settings?.forgeKind ?? 'gitea'}
ghLogin={settings?.ghLogin}
ghLoginError={settings?.ghLoginError}
errorMessage={settings?.errorMessage}
canCancel={settings?.canCancel}
initialTokenSaved={settings?.tokenSaved ?? false}
@@ -117,6 +117,13 @@ describe('设置面板的仓库连接', () => {
expect(html).toContain('未登录')
expect(html).toContain('gh auth login')
})
it('gitHub 仓库连不上 GitHub 时显示原因,不说成没登录', () => {
const html = render({ host: 'github.com', forgeKind: 'github', ghLoginError: '无法连接 GitHub,暂时确认不了登录状态:请求超时', initialTokenSaved: false })
expect(html).toContain('无法连接 GitHub')
expect(html).toContain('请求超时')
expect(html).not.toContain('未登录')
})
})
describe('webhook 配置说明', () => {
@@ -77,6 +77,8 @@ export interface SettingsModalProps {
forgeKind: ForgeKind
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。只在 GitHub 仓库下有意义。 */
ghLogin?: string
/** 查 gh 账号时连不上 GitHub、超时这类失败的说明;有它时不能按没登录显示。 */
ghLoginError?: string
errorMessage?: string
canCancel?: boolean
initialTokenSaved: boolean
@@ -168,7 +170,7 @@ const FIELD_TO_GROUP: Record<ErrorKey, GroupKey> = {
webhookPort: 'network',
}
function GhLoginStatus({ login }: { login?: string }): ReactElement {
function GhLoginStatus({ login, error }: { login?: string, error?: string }): ReactElement {
return (
<div className="mb-3 text-xs">
<span className="mb-1 block opacity-80">GitHub 账号</span>
@@ -180,6 +182,8 @@ function GhLoginStatus({ login }: { login?: string }): ReactElement {
<b>{login}</b>
</span>
)
: error
? <span className="text-state-red">{error}</span>
: (
<span className="text-state-red">
未登录或登录已失效:请在终端运行
@@ -283,6 +287,7 @@ export function SettingsModal({
host: initialHost,
forgeKind,
ghLogin,
ghLoginError,
errorMessage,
canCancel,
initialTokenSaved,
@@ -581,7 +586,7 @@ export function SettingsModal({
{activeGroup === 'auth' && (
<>
{forgeKind === 'github'
? <GhLoginStatus login={ghLogin} />
? <GhLoginStatus login={ghLogin} error={ghLoginError} />
: (
<>
<Field label="Gitea Host" error={errors.host}>
+2
View File
@@ -71,6 +71,7 @@ export interface SettingsOverlayState {
host: string
forgeKind: ForgeKind
ghLogin?: string
ghLoginError?: string
errorMessage?: string
canCancel: boolean
tokenSaved: boolean
@@ -961,6 +962,7 @@ export function useIssues(): UseIssuesResult {
host: msg.host,
forgeKind: msg.forgeKind,
ghLogin: msg.ghLogin,
ghLoginError: msg.ghLoginError,
errorMessage: msg.errorMessage,
canCancel: msg.canCancel === true,
tokenSaved: msg.tokenSaved,
+2
View File
@@ -112,6 +112,8 @@ export type ExtensionToWebview
forgeKind: ForgeKind
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。 */
ghLogin?: string
/** 查 gh 账号时连不上 GitHub、超时这类失败的说明;有它时不能按没登录显示。 */
ghLoginError?: string
errorMessage?: string
canCancel?: boolean
tokenSaved: boolean