diff --git a/vscode/src/auth/identity.test.ts b/vscode/src/auth/identity.test.ts new file mode 100644 index 0000000..90ec8de --- /dev/null +++ b/vscode/src/auth/identity.test.ts @@ -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): 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() + }) +}) diff --git a/vscode/src/auth/identity.ts b/vscode/src/auth/identity.ts index 511740d..afea32a 100644 --- a/vscode/src/auth/identity.ts +++ b/vscode/src/auth/identity.ts @@ -25,13 +25,21 @@ let statusBar: StatusBarItem | undefined * 解析并缓存 `forge` 当前凭据对应的 login。失败(网络、凭据失效)返回 `undefined`,由调用方决定降级:门禁侧的语义是「身份未知 → 只观察」。 */ export async function loginFor(forge: ForgeContext): Promise { + 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 message: `解析身份失败 host=${forge.host}`, details: message, }) - return undefined + return { ok: false, error: err } } } diff --git a/vscode/src/forge/context.test.ts b/vscode/src/forge/context.test.ts index 8942841..57dfd43 100644 --- a/vscode/src/forge/context.test.ts +++ b/vscode/src/forge/context.test.ts @@ -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') diff --git a/vscode/src/forge/context.ts b/vscode/src/forge/context.ts index 567eff5..45ce262 100644 --- a/vscode/src/forge/context.ts +++ b/vscode/src/forge/context.ts @@ -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) diff --git a/vscode/src/forge/github/ghAuth.test.ts b/vscode/src/forge/github/ghAuth.test.ts index 05da22c..7af10f5 100644 --- a/vscode/src/forge/github/ghAuth.test.ts +++ b/vscode/src/forge/github/ghAuth.test.ts @@ -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] 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('未登录') }) }) diff --git a/vscode/src/forge/github/ghAuth.ts b/vscode/src/forge/github/ghAuth.ts index e3f9c96..8028fc5 100644 --- a/vscode/src/forge/github/ghAuth.ts +++ b/vscode/src/forge/github/ghAuth.ts @@ -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 { +/** [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 { 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}` +} diff --git a/vscode/src/forge/github/provider.test.ts b/vscode/src/forge/github/provider.test.ts index 8ddba6c..e498e3c 100644 --- a/vscode/src/forge/github/provider.test.ts +++ b/vscode/src/forge/github/provider.test.ts @@ -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` diff --git a/vscode/src/forge/github/provider.ts b/vscode/src/forge/github/provider.ts index a6c5e0c..17fbee4 100644 --- a/vscode/src/forge/github/provider.ts +++ b/vscode/src/forge/github/provider.ts @@ -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(res: Response): Promise { + 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 { + 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 { 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(url: string): Promise { - return await (await this.send(url)).json() as T + return readJson(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(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 { diff --git a/vscode/src/forge/types.ts b/vscode/src/forge/types.ts index f12d04c..4d3306a 100644 --- a/vscode/src/forge/types.ts +++ b/vscode/src/forge/types.ts @@ -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 diff --git a/vscode/src/panel/handlers/settings.ts b/vscode/src/panel/handlers/settings.ts index f05c2c4..531f544 100644 --- a/vscode/src/panel/handlers/settings.ts +++ b/vscode/src/panel/handlers/settings.ts @@ -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 { +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. */ diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index c4edc8d..88229f6 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -73,6 +73,8 @@ export type ExtensionToWebview forgeKind: ForgeKind /** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。 */ ghLogin?: string + /** 查 gh 账号时连不上 GitHub、超时这类失败的说明;有它时不能按没登录显示。 */ + ghLoginError?: string errorMessage?: string canCancel?: boolean tokenSaved: boolean diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 5b6f3cd..9558cf9 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -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} diff --git a/vscode/webview-ui/src/components/SettingsModal.test.tsx b/vscode/webview-ui/src/components/SettingsModal.test.tsx index a1fa285..40b3018 100644 --- a/vscode/webview-ui/src/components/SettingsModal.test.tsx +++ b/vscode/webview-ui/src/components/SettingsModal.test.tsx @@ -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 配置说明', () => { diff --git a/vscode/webview-ui/src/components/SettingsModal.tsx b/vscode/webview-ui/src/components/SettingsModal.tsx index 61d9314..b8c77b2 100644 --- a/vscode/webview-ui/src/components/SettingsModal.tsx +++ b/vscode/webview-ui/src/components/SettingsModal.tsx @@ -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 = { webhookPort: 'network', } -function GhLoginStatus({ login }: { login?: string }): ReactElement { +function GhLoginStatus({ login, error }: { login?: string, error?: string }): ReactElement { return (
GitHub 账号 @@ -180,15 +182,17 @@ function GhLoginStatus({ login }: { login?: string }): ReactElement { {login} ) - : ( - - 未登录或登录已失效:请在终端运行 - {' '} - gh auth login - {' '} - 登录 github.com,然后回到看板点刷新 - - )} + : error + ? {error} + : ( + + 未登录或登录已失效:请在终端运行 + {' '} + gh auth login + {' '} + 登录 github.com,然后回到看板点刷新 + + )} GitHub 仓库直接用 GitHub CLI(gh)的登录,插件不另存 token;换账号用 {' '} @@ -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' - ? + ? : ( <> diff --git a/vscode/webview-ui/src/hooks/useIssues.ts b/vscode/webview-ui/src/hooks/useIssues.ts index 324d22d..9f2bd5b 100644 --- a/vscode/webview-ui/src/hooks/useIssues.ts +++ b/vscode/webview-ui/src/hooks/useIssues.ts @@ -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, diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index c0c4300..0c1b0fe 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -112,6 +112,8 @@ export type ExtensionToWebview forgeKind: ForgeKind /** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。 */ ghLogin?: string + /** 查 gh 账号时连不上 GitHub、超时这类失败的说明;有它时不能按没登录显示。 */ + ghLoginError?: string errorMessage?: string canCancel?: boolean tokenSaved: boolean