diff --git a/src/main/gitlab/gitlab-paginated-list.ts b/src/main/gitlab/gitlab-paginated-list.ts new file mode 100644 index 00000000000..0ac5b05f8c9 --- /dev/null +++ b/src/main/gitlab/gitlab-paginated-list.ts @@ -0,0 +1,73 @@ +import { + glabApiWithHeaders, + glabHostnameArgs, + glabRepoExecOptions, + parseGlabJsonList, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' + +// GitLab caps per_page at 100. +const PAGE_SIZE = 100 +// Bound guessed pages when a proxy strips, garbles or stalls next-page headers. +const MAX_PROBED_PAGES = 50 + +/** A page after the first failed, so the rows read so far are not the whole list. */ +export class GitLabIncompletePaginationError extends Error {} + +// Older glab lacks NDJSON output and --jq; parse each API page separately. +export async function fetchAllGitLabPages( + repoPath: string, + projectRef: ProjectRef, + endpoint: string, + connectionId: string | null | undefined, + localGitOptions: LocalGitExecOptions, + signal?: AbortSignal +): Promise { + const items: T[] = [] + let probedPages = 0 + let page: number | undefined = 1 + while (page !== undefined) { + signal?.throwIfAborted() + let pageItems: T[] + let nextPageHeader: string | undefined + try { + const { body, headers } = await glabApiWithHeaders( + [ + ...glabHostnameArgs(projectRef, connectionId), + `${endpoint}?per_page=${PAGE_SIZE}&page=${page}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + pageItems = parseGlabJsonList(body) + nextPageHeader = headers['x-next-page'] + } catch (error) { + // A prefix must not pass for the full list; callers keep what they last showed. + if (page > 1) { + throw new GitLabIncompletePaginationError(`GitLab list incomplete after page ${page - 1}`, { + cause: error + }) + } + throw error + } + items.push(...pageItems) + // Follow only a header naming the next page after a non-empty one; a skip would hide rows. + if (pageItems.length > 0 && nextPageHeader?.trim() === String(page + 1)) { + page += 1 + } else if ( + // GitLab ends with an empty header; otherwise probe after full pages. + nextPageHeader === '' || + pageItems.length < PAGE_SIZE + ) { + page = undefined + } else if (probedPages >= MAX_PROBED_PAGES) { + throw new GitLabIncompletePaginationError( + `GitLab list still full after ${page} guessed pages` + ) + } else { + probedPages += 1 + page += 1 + } + } + return items +} diff --git a/src/main/gitlab/glab-api-response.ts b/src/main/gitlab/glab-api-response.ts index 390732710b3..c29a6f7e25b 100644 --- a/src/main/gitlab/glab-api-response.ts +++ b/src/main/gitlab/glab-api-response.ts @@ -32,7 +32,7 @@ export function parseGlabPaginationHeader( return undefined } const parsed = Number.parseInt(value, 10) - return Number.isFinite(parsed) && parsed >= minimum ? parsed : undefined + return Number.isSafeInteger(parsed) && parsed >= minimum ? parsed : undefined } /** A non-list body carrying no GitLab error text — opaque data, so there is nothing to classify. */ diff --git a/src/main/gitlab/pipeline-job-graph.test.ts b/src/main/gitlab/pipeline-job-graph.test.ts new file mode 100644 index 00000000000..852163832b2 --- /dev/null +++ b/src/main/gitlab/pipeline-job-graph.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GlabApiResponse from './glab-api-response' + +const { glabApiWithHeadersMock } = vi.hoisted(() => ({ glabApiWithHeadersMock: vi.fn() })) + +vi.mock('./gl-utils', async () => ({ + ...(await vi.importActual('./glab-api-response')), + glabApiWithHeaders: glabApiWithHeadersMock, + // Serves single-request fetches from the same pipeline, so a reverted fix fails on row counts. + glabExecFileAsync: async (args: string[]) => ({ + stdout: (await glabApiWithHeadersMock(args.slice(1))).body + }), + glabHostnameArgs: vi.fn(() => []), + glabRepoExecOptions: vi.fn(() => ({})) +})) + +import { GitLabIncompletePaginationError } from './gitlab-paginated-list' +import { fetchPipelineJobs } from './pipeline-job-graph' + +/** Serves `/jobs` and `/bridges` 100 per page with GitLab's x-next-page header. */ +function servePipeline( + jobCount: number, + bridgeCount: number, + headers: 'gitlab' | 'stripped' | 'skipping' = 'gitlab' +): void { + glabApiWithHeadersMock.mockImplementation(async (args: string[]) => { + const match = /pipelines\/7\/(jobs|bridges)\?per_page=100(?:&page=(\d+))?$/.exec( + args.at(-1) ?? '' + ) + if (!match) { + throw new Error(`unexpected endpoint ${args.at(-1)}`) + } + const page = Number(match[2] ?? 1) + const total = match[1] === 'jobs' ? jobCount : bridgeCount + const start = (page - 1) * 100 + const items = Array.from({ length: Math.max(0, Math.min(100, total - start)) }, (_, i) => + match[1] === 'jobs' + ? { id: start + i + 1, name: `job-${start + i}` } + : { name: `bridge-${start + i}` } + ) + return { + body: JSON.stringify(items), + headers: + headers === 'stripped' + ? {} + : { + 'x-next-page': + start + 100 < total ? String(page + (headers === 'skipping' ? 3 : 1)) : '' + } + } + }) +} + +describe('fetchPipelineJobs', () => { + beforeEach(() => { + glabApiWithHeadersMock.mockReset() + }) + + it.each([ + // One request per started 100-item page on each endpoint. + { jobs: 130, bridges: 101, requests: 4 }, + { jobs: 1300, bridges: 1010, requests: 24 }, + { jobs: 5001, bridges: 0, requests: 52 } + ])( + 'collects $jobs jobs and $bridges bridges in $requests requests', + async ({ jobs, bridges, requests }) => { + servePipeline(jobs, bridges) + + const rows = await fetchPipelineJobs('/repo', { host: 'gitlab.com', path: 'g/p' }, 7) + + expect(rows.filter((row) => row.name.startsWith('job-'))).toHaveLength(jobs) + expect(rows.filter((row) => row.name.startsWith('bridge-'))).toHaveLength(bridges) + expect(glabApiWithHeadersMock).toHaveBeenCalledTimes(requests) + } + ) + + it('does not skip pages when the next-page header jumps ahead', async () => { + servePipeline(250, 0, 'skipping') + + const rows = await fetchPipelineJobs('/repo', { host: 'gitlab.com', path: 'g/p' }, 7) + + expect(rows.filter((row) => row.name.startsWith('job-'))).toHaveLength(250) + }) + + it('fails rather than return a prefix when guessed pages run out', async () => { + // Without headers the walker guesses up to 50 pages past page 1, so 5,101 jobs never finish. + servePipeline(5101, 0, 'stripped') + + await expect( + fetchPipelineJobs('/repo', { host: 'gitlab.com', path: 'g/p' }, 7) + ).rejects.toBeInstanceOf(GitLabIncompletePaginationError) + }) +}) diff --git a/src/main/gitlab/pipeline-job-graph.ts b/src/main/gitlab/pipeline-job-graph.ts index 1f4b350bfb6..aa565f6d8d2 100644 --- a/src/main/gitlab/pipeline-job-graph.ts +++ b/src/main/gitlab/pipeline-job-graph.ts @@ -1,17 +1,11 @@ import type { GitLabPipelineJob } from '../../shared/gitlab-types' import { encodedProject } from './project-path-encoding' import { gitLabRunTimes } from './gitlab-run-times' -import { - glabHostnameArgs, - glabRepoExecOptions, - glabExecFileAsync, - type LocalGitExecOptions, - type ProjectRef -} from './gl-utils' +import { fetchAllGitLabPages, GitLabIncompletePaginationError } from './gitlab-paginated-list' +import type { LocalGitExecOptions, ProjectRef } from './gl-utils' // Child CI jobs live under bridge pipelines, not the parent pipeline's jobs endpoint. -const PIPELINE_JOB_PAGE_SIZE = 100 // Bound child-pipeline expansion per details load. const MAX_CHILD_PIPELINES_TO_EXPAND = 20 // Each fetch launches glab; cap polling fan-out to match the shared CLI admission limit. @@ -75,26 +69,23 @@ function mapBridgeAsJob(raw: GitLabRawBridge, pipelineId: number): GitLabPipelin } } -async function fetchPipelineJobPage( +async function fetchPipelineJobList( repoPath: string, projectRef: ProjectRef, pipelineId: number, connectionId: string | null | undefined, - localGitOptions: LocalGitExecOptions + localGitOptions: LocalGitExecOptions, + signal: AbortSignal ): Promise { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=${PIPELINE_JOB_PAGE_SIZE}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) + const jobs = await fetchAllGitLabPages( + repoPath, + projectRef, + `projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs`, + connectionId, + localGitOptions, + signal ) - const data = JSON.parse(stdout) as GitLabRawJob[] - if (!Array.isArray(data)) { - return [] - } - return data.map((job) => mapPipelineJob(job, pipelineId)) + return jobs.map((job) => mapPipelineJob(job, pipelineId)) } async function fetchPipelineBridges( @@ -102,18 +93,17 @@ async function fetchPipelineBridges( projectRef: ProjectRef, pipelineId: number, connectionId: string | null | undefined, - localGitOptions: LocalGitExecOptions + localGitOptions: LocalGitExecOptions, + signal: AbortSignal ): Promise { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/bridges?per_page=${PIPELINE_JOB_PAGE_SIZE}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) + return fetchAllGitLabPages( + repoPath, + projectRef, + `projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/bridges`, + connectionId, + localGitOptions, + signal ) - const data = JSON.parse(stdout) as GitLabRawBridge[] - return Array.isArray(data) ? data : [] } function childPipelineTarget( @@ -173,11 +163,35 @@ export async function fetchPipelineJobs( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { + // Once the graph has failed, stop sibling page walks nobody will read. + const graphFailed = new AbortController() + const failGraph = (error: unknown): never => { + graphFailed.abort() + throw error + } + // Bridges and children tolerate a failed first page (e.g. a forbidden child project), never a partial list. + const tolerateUnreadable = + (fallback: T) => + (error: unknown): T => + error instanceof GitLabIncompletePaginationError ? failGraph(error) : fallback + const [parentJobs, bridges] = await Promise.all([ - fetchPipelineJobPage(repoPath, projectRef, pipelineId, connectionId, localGitOptions), - fetchPipelineBridges(repoPath, projectRef, pipelineId, connectionId, localGitOptions).catch( - () => [] as GitLabRawBridge[] - ) + fetchPipelineJobList( + repoPath, + projectRef, + pipelineId, + connectionId, + localGitOptions, + graphFailed.signal + ).catch(failGraph), + fetchPipelineBridges( + repoPath, + projectRef, + pipelineId, + connectionId, + localGitOptions, + graphFailed.signal + ).catch(tolerateUnreadable([] as GitLabRawBridge[])) ]) const bridgeRows = bridges.map((bridge) => mapBridgeAsJob(bridge, pipelineId)) @@ -199,13 +213,14 @@ export async function fetchPipelineJobs( childTargets, MAX_CONCURRENT_CHILD_FETCHES, (target) => - fetchPipelineJobPage( + fetchPipelineJobList( repoPath, target.projectRef, target.pipelineId, connectionId, - localGitOptions - ).catch(() => [] as GitLabPipelineJob[]) + localGitOptions, + graphFailed.signal + ).catch(tolerateUnreadable([] as GitLabPipelineJob[])) ) // Keep parent/child job order, and add bridge rollups only when no expanded job shares their name. diff --git a/src/main/gitlab/project-label-and-member-lookup.ts b/src/main/gitlab/project-label-and-member-lookup.ts index ba9fa5ff0d4..8b38fbb1c77 100644 --- a/src/main/gitlab/project-label-and-member-lookup.ts +++ b/src/main/gitlab/project-label-and-member-lookup.ts @@ -4,22 +4,13 @@ import { mapGitLabUser, type GitLabRawUser } from './gitlab-assignable-user-mapp import { acquire, getGlabKnownHosts, - glabApiWithHeaders, - glabHostnameArgs, - glabRepoExecOptions, - parseGlabJsonList, - parseGlabPaginationHeader, release, resolveIssueSource, - type LocalGitExecOptions, - type ProjectRef + type LocalGitExecOptions } from './gl-utils' +import { fetchAllGitLabPages } from './gitlab-paginated-list' import { encodedProject } from './project-path-encoding' -const PAGE_SIZE = 100 -// Bound pagination when a proxy strips or fails to advance next-page headers. -const MAX_PAGES = 50 - type GitLabRawLabel = { name?: string | null } | null export async function listLabels( @@ -41,7 +32,7 @@ export async function listLabels( } await acquire() try { - const labels = await fetchAllPages( + const labels = await fetchAllGitLabPages( repoPath, projectRef, `projects/${encodedProject(projectRef.path)}/labels`, @@ -76,7 +67,7 @@ export async function listAssignableUsers( await acquire() try { // Include members inherited from parent groups, where assignable project users commonly originate. - const members = await fetchAllPages( + const members = await fetchAllGitLabPages( repoPath, projectRef, `projects/${encodedProject(projectRef.path)}/members/all`, @@ -90,40 +81,3 @@ export async function listAssignableUsers( release() } } - -// Older glab lacks NDJSON output and --jq; parse each API page separately. -async function fetchAllPages( - repoPath: string, - projectRef: ProjectRef, - endpoint: string, - connectionId: string | null | undefined, - localGitOptions: LocalGitExecOptions -): Promise { - const items: T[] = [] - let page: number | undefined = 1 - for (let fetched = 0; page !== undefined && fetched < MAX_PAGES; fetched += 1) { - const { body, headers } = await glabApiWithHeaders( - [ - ...glabHostnameArgs(projectRef, connectionId), - `${endpoint}?per_page=${PAGE_SIZE}&page=${page}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const pageItems = parseGlabJsonList(body) - items.push(...pageItems) - page = nextPageNumber(headers['x-next-page'], page, pageItems.length) - } - return items -} - -function nextPageNumber( - nextPageHeader: string | undefined, - page: number, - pageLength: number -): number | undefined { - // GitLab ends with an empty header; an absent header may be proxy-stripped, so probe after full pages. - if (nextPageHeader === undefined) { - return pageLength < PAGE_SIZE ? undefined : page + 1 - } - return parseGlabPaginationHeader(nextPageHeader, page + 1) -} diff --git a/src/main/gitlab/work-item-details.test.ts b/src/main/gitlab/work-item-details.test.ts index f5ee71548ec..db1ea12d2be 100644 --- a/src/main/gitlab/work-item-details.test.ts +++ b/src/main/gitlab/work-item-details.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GlabApiResponse from './glab-api-response' const { glabExecFileAsyncMock, @@ -22,7 +23,12 @@ const { releaseMock: vi.fn() })) -vi.mock('./gl-utils', () => ({ +vi.mock('./gl-utils', async () => ({ + ...(await vi.importActual('./glab-api-response')), + glabApiWithHeaders: async (args: string[], options: unknown) => ({ + body: (await glabExecFileAsyncMock(['api', ...args], options)).stdout, + headers: {} + }), acquire: acquireMock, release: releaseMock, getGlabKnownHosts: getGlabKnownHostsMock, @@ -72,7 +78,7 @@ describe('getWorkItemDetails', () => { }) } } - if (endpoint === 'projects/g%2Fp/pipelines/99/bridges?per_page=100') { + if (endpoint === 'projects/g%2Fp/pipelines/99/bridges?per_page=100&page=1') { return { stdout: JSON.stringify( Array.from({ length: BRIDGE_COUNT }, (_, i) => ({ @@ -110,4 +116,91 @@ describe('getWorkItemDetails', () => { expect(peakJobPages).toBeLessThanOrEqual(5) expect(details?.pipelineJobs).toHaveLength(BRIDGE_COUNT) }) + + // Parent pipeline 99 has 150 jobs and 100 bridges, one to child pipeline 1000, which has 150 jobs. + function servePipelineGraph(failing: { endpoint: string; page: number; message: string }): void { + glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.at(-1) as string + if (endpoint === 'projects/g%2Fp/merge_requests/12') { + return { + stdout: JSON.stringify({ + iid: 12, + title: 'Graph', + state: 'opened', + head_pipeline: { id: 99 } + }) + } + } + const listMatch = + /^(projects\/g%2Fp\/pipelines\/\d+\/(?:jobs|bridges))\?per_page=100&page=(\d+)$/.exec( + endpoint + ) + if (!listMatch) { + return { stdout: endpoint.endsWith('/approvals') ? '{}' : '[]' } + } + const [, list, pageText] = listMatch + const page = Number(pageText) + if (list === failing.endpoint && page === failing.page) { + throw new Error(failing.message) + } + if (list.endsWith('/bridges')) { + // A full first page makes the walker request page 2. + const bridges = list.includes('/99/') + ? [ + { name: 'child', downstream_pipeline: { id: 1000, status: 'success' } }, + ...Array.from({ length: 99 }, (_, i) => ({ name: `trigger-${i}` })) + ] + : [] + return { stdout: JSON.stringify(page === 1 ? bridges : []) } + } + const pipelineId = Number(/pipelines\/(\d+)/.exec(list)?.[1]) + const count = page === 1 ? 100 : page === 2 ? 50 : 0 + return { + stdout: JSON.stringify( + Array.from({ length: count }, (_, i) => ({ + id: pipelineId * 1000 + (page - 1) * 100 + i + 1, + name: `job-${pipelineId}-${(page - 1) * 100 + i}` + })) + ) + } + }) + } + + it.each([ + { endpoint: 'projects/g%2Fp/pipelines/99/jobs', includeReviewDetails: true }, + { endpoint: 'projects/g%2Fp/pipelines/99/jobs', includeReviewDetails: false }, + { endpoint: 'projects/g%2Fp/pipelines/1000/jobs', includeReviewDetails: false }, + { endpoint: 'projects/g%2Fp/pipelines/99/bridges', includeReviewDetails: false } + ])( + 'fails the read when $endpoint page 2 fails (includeReviewDetails=$includeReviewDetails)', + async ({ endpoint, includeReviewDetails }) => { + servePipelineGraph({ endpoint, page: 2, message: '503 upstream unavailable' }) + + const details = await getWorkItemDetails( + '/repo', + 12, + 'mr', + undefined, + undefined, + undefined, + undefined, + { includeReviewDetails } + ) + + expect(details).toBeNull() + } + ) + + it('keeps the graph when a child project forbids its first page', async () => { + servePipelineGraph({ + endpoint: 'projects/g%2Fp/pipelines/1000/jobs', + page: 1, + message: '403 Forbidden' + }) + + const details = await getWorkItemDetails('/repo', 12, 'mr') + + expect(details?.pipelineJobs?.filter((job) => job.name.startsWith('job-99-'))).toHaveLength(150) + expect(details?.pipelineJobs?.some((job) => job.name === 'child')).toBe(true) + }) }) diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index 832cf176242..980a29c52bd 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -16,6 +16,7 @@ import { fetchDiscussions, flattenDiscussions } from './mr-discussion-notes' import { fetchMRFiles } from './mr-file-diffs' import { fetchMRApprovalState, fetchMRReviewers } from './mr-reviewers-and-approvals' import { fetchPipelineJobs } from './pipeline-job-graph' +import { GitLabIncompletePaginationError } from './gitlab-paginated-list' import { gitLabRunTimes } from './gitlab-run-times' import { acquire, @@ -163,7 +164,13 @@ async function fetchMRDetails( pipelineId, connectionId, localGitOptions - ).catch(() => []) + ).catch((error: unknown) => { + // A partial job list must fail the read so Checks and the dialog keep what they showed. + if (error instanceof GitLabIncompletePaginationError) { + throw error + } + return [] + }) : undefined // Checks needs pipeline/discussion data without the dialog's optional review enrichment. const includeReviewDetails = options.includeReviewDetails !== false diff --git a/src/renderer/src/components/gitlab-item-dialog/gitlab-pipeline-tab.tsx b/src/renderer/src/components/gitlab-item-dialog/gitlab-pipeline-tab.tsx index c136ee5e29b..cb311e7bdd2 100644 --- a/src/renderer/src/components/gitlab-item-dialog/gitlab-pipeline-tab.tsx +++ b/src/renderer/src/components/gitlab-item-dialog/gitlab-pipeline-tab.tsx @@ -23,7 +23,7 @@ export function GitLabPipelineTab({ item, state, pipelineActions }: Props) { if (item.type !== 'mr') { return null } - const { details, expandedJobId, jobTraceById, loading, retryingJobId } = state + const { details, error, expandedJobId, jobTraceById, loading, retryingJobId } = state const pipelineCheck = details?.pipeline ? gitLabPipelineToPRCheck( details.pipeline, @@ -73,7 +73,7 @@ export function GitLabPipelineTab({ item, state, pipelineActions }: Props) { /> ))} - ) : !pipelineCheck ? ( + ) : !pipelineCheck && !(error && !details) ? (

{translate( 'auto.components.GitLabItemDialog.f11e3e7675', diff --git a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-item-dialog-effects.ts b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-item-dialog-effects.ts index 9e7122226a3..ab88a3a5ce7 100644 --- a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-item-dialog-effects.ts +++ b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-item-dialog-effects.ts @@ -1,3 +1,4 @@ +import { translate } from '@/i18n/i18n' import { gitLabApiFor } from '@/runtime/gitlab-owner-api' import { useEffect } from 'react' import type { GitLabWorkItem, GitLabWorkItemDetails } from '../../../../shared/gitlab-types' @@ -28,7 +29,13 @@ export function useGitLabItemDetailsEffect( return } if (!data) { - setError('Item not found.') + // Main returns null for any failed read, not only a missing item. + setError( + translate( + 'auto.components.GitLabItemDialog.itemLoadFailed', + 'Could not load this item. Refresh to try again.' + ) + ) return } setDetails(data as GitLabWorkItemDetails) diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index c153a67ab7a..880f99dce49 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -2320,6 +2320,7 @@ "cb55b0390f": "Verwalten", "4f9313984d": "Prüfer", "02cbe2de44": "Pipeline", + "itemLoadFailed": "Dieses Element konnte nicht geladen werden. Aktualisiere, um es erneut zu versuchen.", "be3d291837": "Dateien", "c996e2962c": "Unterhaltung", "b3c156dd51": "Aktualisieren", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 402d23f75f1..a54bfc9497c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2538,6 +2538,7 @@ "cb55b0390f": "Manage", "4f9313984d": "Reviewers", "02cbe2de44": "Pipeline", + "itemLoadFailed": "Could not load this item. Refresh to try again.", "be3d291837": "Files", "c996e2962c": "Conversation", "b3c156dd51": "Refresh", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d53b789db09..28e29bf72e8 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1889,6 +1889,7 @@ "cb55b0390f": "Gestionar", "4f9313984d": "Revisores", "02cbe2de44": "Pipeline", + "itemLoadFailed": "No se pudo cargar este elemento. Actualiza para volver a intentarlo.", "be3d291837": "Archivos", "c996e2962c": "Conversación", "b3c156dd51": "Actualizar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 60a5718de79..f62b60911c5 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -2113,6 +2113,7 @@ "cb55b0390f": "Gérer", "4f9313984d": "Reviewers", "02cbe2de44": "Pipeline", + "itemLoadFailed": "Impossible de charger cet élément. Actualisez pour réessayer.", "be3d291837": "Fichiers", "c996e2962c": "Conversation", "b3c156dd51": "Actualiser", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d6b2bd33a22..e91fea17194 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1859,6 +1859,7 @@ "cb55b0390f": "管理", "4f9313984d": "レビュアー", "02cbe2de44": "パイプライン", + "itemLoadFailed": "この項目を読み込めませんでした。更新して再試行してください。", "be3d291837": "ファイル", "c996e2962c": "会話", "b3c156dd51": "更新", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 28e98113f8f..d9498aafd0f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1859,6 +1859,7 @@ "cb55b0390f": "관리", "4f9313984d": "리뷰어", "02cbe2de44": "파이프라인", + "itemLoadFailed": "이 항목을 불러올 수 없습니다. 새로 고쳐 다시 시도하세요.", "be3d291837": "파일", "c996e2962c": "대화", "b3c156dd51": "새로고침", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 539bcc95abe..fb65cdc1728 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -17859,6 +17859,7 @@ "cb55b0390f": "Gerenciar", "4f9313984d": "Revisores", "02cbe2de44": "Pipeline", + "itemLoadFailed": "Não foi possível carregar este item. Atualize para tentar novamente.", "be3d291837": "Arquivos", "c996e2962c": "Conversa", "b3c156dd51": "Atualizar", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index e591e62f955..72d3accef0a 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -2376,6 +2376,7 @@ "cb55b0390f": "Управление", "4f9313984d": "Рецензенты", "02cbe2de44": "Конвейер", + "itemLoadFailed": "Не удалось загрузить этот элемент. Обновите, чтобы повторить попытку.", "be3d291837": "Файлы", "c996e2962c": "Обсуждение", "b3c156dd51": "Обновить", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 6f2a17d3a67..0e6fa754f44 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -2298,6 +2298,7 @@ "cb55b0390f": "Yönet", "4f9313984d": "İnceleyenler", "02cbe2de44": "Pipeline", + "itemLoadFailed": "Bu öğe yüklenemedi. Tekrar denemek için yenileyin.", "be3d291837": "Dosyalar", "c996e2962c": "Görüşme", "b3c156dd51": "Yenile", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 382dfe4f5c7..cf3eda16dad 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -2268,6 +2268,7 @@ "cb55b0390f": "管理", "4f9313984d": "審查人", "02cbe2de44": "管線", + "itemLoadFailed": "無法載入此項目。請重新整理以再試一次。", "be3d291837": "檔案", "c996e2962c": "對話", "b3c156dd51": "重新整理", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c264898a589..4a388cd75c7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1859,6 +1859,7 @@ "cb55b0390f": "管理", "4f9313984d": "评审人", "02cbe2de44": "管道", + "itemLoadFailed": "无法加载此项目。请刷新后重试。", "be3d291837": "文件", "c996e2962c": "对话", "b3c156dd51": "刷新",