diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 6a3a57bebe8..d48c28d2540 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: GitLab remote parsing coverage needs many URL/host fixtures against the same mocked git/glab helpers. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ @@ -22,96 +21,12 @@ import { getGlabKnownHosts, getProjectRef, getProjectRefForRemote, - parseGitLabProjectRef, parseGlabApiResponse, parseGlabAuthStatusHosts, resolveIssueSource } from './gl-utils' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' -describe('gitlab project ref parsing', () => { - it('parses HTTPS and SSH GitLab.com remotes', () => { - expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({ - host: 'gitlab.com', - path: 'acme/widgets' - }) - expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({ - host: 'gitlab.com', - path: 'stablyai/orca' - }) - }) - - it('preserves nested group paths', () => { - expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({ - host: 'gitlab.com', - path: 'group/subgroup/project' - }) - expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({ - host: 'gitlab.com', - path: 'g1/g2/g3/proj' - }) - }) - - it('returns null for non-GitLab hosts when host not in knownHosts', () => { - expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull() - expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull() - }) - - it('matches self-hosted hosts when included in knownHosts', () => { - expect( - parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [ - 'gitlab.com', - 'gitlab.example.com' - ]) - ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) - }) - - it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => { - expect( - parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [ - 'gitlab.com', - 'gitlab.example.com' - ]) - ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) - expect( - parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [ - 'gitlab.com', - 'gitlab.example.com' - ]) - ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) - }) - - it('rejects single-segment paths (host root or user-only)', () => { - expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull() - expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull() - }) - - it('handles missing .git suffix', () => { - expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({ - host: 'gitlab.com', - path: 'acme/widgets' - }) - }) - - it('strips trailing slashes after .git suffixes', () => { - expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git/')).toEqual({ - host: 'gitlab.com', - path: 'acme/widgets' - }) - expect(parseGitLabProjectRef('ssh://git@gitlab.com/acme/widgets.git/')).toEqual({ - host: 'gitlab.com', - path: 'acme/widgets' - }) - }) - - it('preserves git protocol remote support', () => { - expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({ - host: 'gitlab.com', - path: 'acme/widgets' - }) - }) -}) - describe('gitlab project ref resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() @@ -376,6 +291,24 @@ gitlab.example.com: expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com') }) + it('extracts hosts from bare auth-status section headers', () => { + const out = ` +gitlab.com + ✓ Logged in to gitlab.com as user1 (/home/user/.config/glab-cli/config.yml) + ✓ Token: ************************** +gitlab.internal + ✓ Logged in as user2 + ✓ Token: ************************** +Self-hosted-git + ✓ Logged in as user3 + ` + expect(parseGlabAuthStatusHosts(out).sort()).toEqual([ + 'gitlab.com', + 'gitlab.internal', + 'self-hosted-git' + ]) + }) + it('returns empty list for output with no hosts', () => { expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([]) }) diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index 9ae15ef23f9..e65f6fecf28 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -1,9 +1,16 @@ import { execFile } from 'child_process' import { promisify } from 'util' import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' -import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types' +import type { ClassifiedError, IssueSourcePreference } from '../../shared/types' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' +import { + DEFAULT_GITLAB_HOSTS, + normalizeGitLabHost, + parseGitLabProjectRef, + parseRemoteProjectRefCandidate, + type ProjectRef +} from './project-ref-parser' // Why: legacy generic execFile wrapper — only used by callers that don't need // WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from @@ -98,11 +105,8 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { return { type: c.type, message: readMessages[c.type] } } -// ── Project ref resolution ────────────────────────────────────────── -// Why: alias the shared shape so `src/shared/types.ts#GitLabProjectRef` -// remains the single source of truth while main-side call sites can use -// the short local name `ProjectRef`. -export type ProjectRef = GitLabProjectRef +export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef } +export type { ProjectRef } const PROJECT_REF_CACHE_MAX_ENTRIES = 512 const projectRefCache = new Map() @@ -129,61 +133,6 @@ function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null } } -/** - * Hosts always treated as GitLab. Self-hosted instances are added at - * runtime via `getGlabKnownHosts()`, which inspects `glab auth status`. - */ -export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const - -function normalizeHost(value: string): string { - return value.trim().toLowerCase() -} - -function stripGitSuffix(path: string): string { - return path.replace(/\/+$/, '').replace(/\.git$/i, '') -} - -function makeProjectRef( - host: string, - path: string, - knownHosts: readonly string[] -): ProjectRef | null { - const normalizedHost = normalizeHost(host) - if (!knownHosts.map(normalizeHost).includes(normalizedHost)) { - return null - } - const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim() - // Reject paths without at least one group segment — `gitlab.com:foo` - // alone is not a project reference. - if (!normalizedPath.includes('/')) { - return null - } - return { host: normalizedHost, path: normalizedPath } -} - -export function parseGitLabProjectRef( - remoteUrl: string, - knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS -): ProjectRef | null { - const trimmed = remoteUrl.trim() - if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { - const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) - if (scpLike) { - return makeProjectRef(scpLike[1], scpLike[2], knownHosts) - } - } - - try { - const url = new URL(trimmed) - if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) { - return null - } - return makeProjectRef(url.hostname, url.pathname, knownHosts) - } catch { - return null - } -} - export async function getProjectRefForRemote( repoPath: string, remoteName: string, @@ -224,6 +173,17 @@ async function resolveProjectRefForRemote( rememberProjectRefCacheEntry(cacheKey, result) return result } + const remoteCandidate = parseRemoteProjectRefCandidate(stdout) + if ( + remoteCandidate && + (await isGlabConfiguredForRemoteHost(repoPath, remoteCandidate, connectionId)) + ) { + // Why: `glab auth status` is process-global and can be stale or formatted + // differently across versions; the origin host itself is the durable repo context. + rememberGlabKnownHost(remoteCandidate.host) + rememberProjectRefCacheEntry(cacheKey, remoteCandidate) + return remoteCandidate + } } catch { if (connectionId) { // Why: remote SSH failures are often transient tunnel/process errors. @@ -315,6 +275,36 @@ export function glabHostnameArgs( let knownHostsCache: readonly string[] | null = null +function rememberGlabKnownHost(host: string): void { + const normalizedHost = normalizeGitLabHost(host) + if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) { + return + } + knownHostsCache = [...knownHostsCache, normalizedHost] +} + +async function isGlabConfiguredForRemoteHost( + repoPath: string, + projectRef: Pick, + connectionId?: string | null +): Promise { + try { + const result = await glabExecFileAsync( + ['auth', 'status', '--hostname', projectRef.host], + glabRepoExecOptions(repoPath, connectionId) + ) + return result !== undefined + } catch (error) { + const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown } + const output = + [execLike.stdout, execLike.stderr, execLike.message] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join('\n') || String(error) + const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost) + return hosts.includes(normalizeGitLabHost(projectRef.host)) + } +} + /** @internal — exposed for tests only */ export function _resetKnownHostsCache(): void { knownHostsCache = null @@ -397,9 +387,10 @@ export function parseGlabAuthStatusHosts(output: string): string[] { hosts.add(m[1].toLowerCase()) } for (const line of output.split('\n')) { - const m = line.match(/^([a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}):\s*$/) - if (m) { - hosts.add(m[1].toLowerCase()) + const bareLine = line.trim() + const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine + if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) { + hosts.add(hostLine.toLowerCase()) } } return Array.from(hosts) diff --git a/src/main/gitlab/project-ref-parser.test.ts b/src/main/gitlab/project-ref-parser.test.ts new file mode 100644 index 00000000000..53adc0c5aa5 --- /dev/null +++ b/src/main/gitlab/project-ref-parser.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { parseGitLabProjectRef, parseRemoteProjectRefCandidate } from './project-ref-parser' + +describe('gitlab project ref parsing', () => { + it('parses HTTPS and SSH GitLab.com remotes', () => { + expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({ + host: 'gitlab.com', + path: 'stablyai/orca' + }) + }) + + it('preserves nested group paths', () => { + expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({ + host: 'gitlab.com', + path: 'group/subgroup/project' + }) + expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({ + host: 'gitlab.com', + path: 'g1/g2/g3/proj' + }) + }) + + it('returns null for non-GitLab hosts when host not in knownHosts', () => { + expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull() + expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull() + }) + + it('matches self-hosted hosts when included in knownHosts', () => { + expect( + parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [ + 'gitlab.com', + 'gitlab.example.com' + ]) + ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) + }) + + it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => { + expect( + parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [ + 'gitlab.com', + 'gitlab.example.com' + ]) + ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) + expect( + parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [ + 'gitlab.com', + 'gitlab.example.com' + ]) + ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) + }) + + it('rejects single-segment paths (host root or user-only)', () => { + expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull() + expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull() + }) + + it('handles missing .git suffix', () => { + expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + }) + + it('strips trailing slashes after .git suffixes', () => { + expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git/')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + expect(parseGitLabProjectRef('ssh://git@gitlab.com/acme/widgets.git/')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + }) + + it('preserves git protocol remote support', () => { + expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + }) +}) + +describe('gitlab remote project ref candidates', () => { + it('extracts self-hosted candidates before the host is trusted', () => { + expect(parseRemoteProjectRefCandidate('git@gitlab.internal:team/orca.git')).toEqual({ + host: 'gitlab.internal', + path: 'team/orca' + }) + }) + + it('rejects non-git URLs and single-segment project paths', () => { + expect(parseRemoteProjectRefCandidate('file:///tmp/repo')).toBeNull() + expect(parseRemoteProjectRefCandidate('git@gitlab.internal:team.git')).toBeNull() + }) +}) diff --git a/src/main/gitlab/project-ref-parser.ts b/src/main/gitlab/project-ref-parser.ts new file mode 100644 index 00000000000..e42c0e2081b --- /dev/null +++ b/src/main/gitlab/project-ref-parser.ts @@ -0,0 +1,84 @@ +import type { GitLabProjectRef } from '../../shared/types' + +export type ProjectRef = GitLabProjectRef + +/** + * Hosts always treated as GitLab. Self-hosted instances are added at + * runtime via `getGlabKnownHosts()`, which inspects `glab auth status`. + */ +export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const + +export function normalizeGitLabHost(value: string): string { + return value.trim().toLowerCase() +} + +function stripGitSuffix(path: string): string { + return path.replace(/\/+$/, '').replace(/\.git$/i, '') +} + +function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null { + const normalizedHost = normalizeGitLabHost(host) + const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim() + // Reject paths without at least one group segment — `gitlab.com:foo` + // alone is not a project reference. + if (!normalizedPath.includes('/')) { + return null + } + return { host: normalizedHost, path: normalizedPath } +} + +function makeProjectRef( + host: string, + path: string, + knownHosts: readonly string[] +): ProjectRef | null { + const normalizedHost = normalizeGitLabHost(host) + const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost) + if (!normalizedKnownHosts.includes(normalizedHost)) { + return null + } + return makeProjectRefForTrustedHost(normalizedHost, path) +} + +export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef | null { + const trimmed = remoteUrl.trim() + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) + if (scpLike) { + return makeProjectRefForTrustedHost(scpLike[1], scpLike[2]) + } + } + + try { + const url = new URL(trimmed) + if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) { + return null + } + return makeProjectRefForTrustedHost(url.hostname, url.pathname) + } catch { + return null + } +} + +export function parseGitLabProjectRef( + remoteUrl: string, + knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS +): ProjectRef | null { + const trimmed = remoteUrl.trim() + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) + if (scpLike) { + return makeProjectRef(scpLike[1], scpLike[2], knownHosts) + } + } + + try { + const url = new URL(trimmed) + if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) { + return null + } + return makeProjectRef(url.hostname, url.pathname, knownHosts) + } catch { + return null + } +} diff --git a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts new file mode 100644 index 00000000000..c801d7db454 --- /dev/null +++ b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + gitExecFileAsyncMock, + glabExecFileAsyncMock, + ghExecFileAsyncMock, + getAzureDevOpsRepoSlugMock, + getBitbucketRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + getRepoSlugMock, + getSshGitProviderMock +} = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + glabExecFileAsyncMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + getAzureDevOpsRepoSlugMock: vi.fn(), + getBitbucketRepoSlugMock: vi.fn(), + getGiteaRepoSlugMock: vi.fn(), + getHostedReviewForBranchMock: vi.fn(), + getRepoSlugMock: vi.fn(), + getSshGitProviderMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + glabExecFileAsync: glabExecFileAsyncMock, + ghExecFileAsync: ghExecFileAsyncMock, + extractExecError: vi.fn() +})) + +vi.mock('../github/client', () => ({ + createGitHubPullRequest: vi.fn(), + getRepoSlug: getRepoSlugMock, + getPRForBranch: vi.fn() +})) + +vi.mock('../bitbucket/client', () => ({ + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: vi.fn(), + getBitbucketPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: vi.fn(), + getAzureDevOpsPullRequest: vi.fn() +})) + +vi.mock('../gitea/client', () => ({ + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: vi.fn(), + getGiteaPullRequest: vi.fn() +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +vi.mock('./hosted-review', () => ({ + getHostedReviewForBranch: getHostedReviewForBranchMock +})) + +import { _resetKnownHostsCache, _resetProjectRefCache } from '../gitlab/gl-utils' +import { getHostedReviewCreationEligibility } from './hosted-review-creation' + +function resetMocks(): void { + for (const mock of [ + gitExecFileAsyncMock, + glabExecFileAsyncMock, + ghExecFileAsyncMock, + getAzureDevOpsRepoSlugMock, + getBitbucketRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + getRepoSlugMock, + getSshGitProviderMock + ]) { + mock.mockReset() + } + _resetKnownHostsCache() + _resetProjectRefCache() +} + +function mockNonGitLabProviders(): void { + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + +describe('GitLab self-hosted hosted review creation eligibility', () => { + beforeEach(() => { + resetMocks() + mockNonGitLabProviders() + getHostedReviewForBranchMock.mockResolvedValue(null) + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'git@gitlab.internal:team/orca.git\n', + stderr: '' + }) + }) + + it('enables MR creation when glab recognizes the self-hosted origin host', async () => { + glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'auth' && args[1] === 'status' && args.includes('--hostname')) { + return { + stdout: `gitlab.internal + ✓ Logged in as user +`, + stderr: '' + } + } + if (args[0] === 'auth' && args[1] === 'status') { + return { + stdout: `gitlab.com + ✓ Logged in to gitlab.com as user +`, + stderr: '' + } + } + return { stdout: '', stderr: '' } + }) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/self-hosted-mr', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'gitlab', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/self-hosted-mr' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'gitlab.internal'], + { cwd: '/repo' } + ) + }) + + it('classifies known-but-unauthenticated self-hosted GitLab as auth_required', async () => { + glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'auth' && args[1] === 'status' && args.includes('--hostname')) { + const error = new Error('invalid token provided') as Error & { + stdout: string + stderr: string + } + error.stdout = `gitlab.internal + ! Invalid token provided +` + error.stderr = '' + throw error + } + if (args[0] === 'auth' && args[1] === 'status') { + return { + stdout: `gitlab.com + ✓ Logged in to gitlab.com as user +gitlab.internal + ! Invalid token provided +`, + stderr: '' + } + } + return { stdout: '', stderr: '' } + }) + + const result = await getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/self-hosted-mr', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + expect(result).toMatchObject({ + provider: 'gitlab', + canCreate: false, + blockedReason: 'auth_required', + nextAction: 'authenticate' + }) + }) +}) diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 0c4ed3cf98f..43409635549 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -1,21 +1,15 @@ -import { ArrowLeft, ArrowRight, Copy, RefreshCw, Smartphone, Trash2 } from 'lucide-react' +import { ArrowLeft, ArrowRight, Copy, RefreshCw } from 'lucide-react' import { cn } from '../../lib/utils' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons' export { HeroIntro } from './MobileHeroIntro' +export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices' import { translate } from '@/i18n/i18n' export type Platform = 'ios' | 'android' export type StepIndex = 0 | 1 -export type PairedDevice = { - deviceId: string - name: string - pairedAt: number - lastSeenAt: number -} - // Why: header copy needs to refer to the *user's* device by its native name. function getDeviceLabel(): string { const ua = navigator.userAgent @@ -28,81 +22,6 @@ function getDeviceLabel(): string { return 'computer' } -type HeroPairedProps = { - devices: readonly PairedDevice[] - onPairAnother: () => void - onRevoke: (deviceId: string) => void - revokingDeviceIds: readonly string[] -} - -export function HeroPaired({ - devices, - onPairAnother, - onRevoke, - revokingDeviceIds -}: HeroPairedProps): React.JSX.Element { - return ( -
-
- - {translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')} - -
-

- {devices.length === 1 - ? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.') - : translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')} -

-

- {translate( - 'auto.components.mobile.MobileHero.266c18c105', - 'Open Orca Mobile to pick up where you left off, or pair another device.' - )} -

-
    - {devices.map((device) => { - const revoking = revokingDeviceIds.includes(device.deviceId) - return ( -
  • -
    - -
    -
    -
    {device.name}
    -
    - {translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '} - {new Date(device.pairedAt).toLocaleDateString()} -
    -
    - -
  • - ) - })} -
-
- - -
-
- ) -} - type HeroFlowProps = { stepIdx: StepIndex platform: Platform diff --git a/src/renderer/src/components/mobile/MobileHeroPairedDevices.tsx b/src/renderer/src/components/mobile/MobileHeroPairedDevices.tsx new file mode 100644 index 00000000000..4202c92ab51 --- /dev/null +++ b/src/renderer/src/components/mobile/MobileHeroPairedDevices.tsx @@ -0,0 +1,84 @@ +import { Smartphone, Trash2 } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +export type PairedDevice = { + deviceId: string + name: string + pairedAt: number + lastSeenAt: number +} + +type HeroPairedProps = { + devices: readonly PairedDevice[] + onPairAnother: () => void + onRevoke: (deviceId: string) => void + revokingDeviceIds: readonly string[] +} + +export function HeroPaired({ + devices, + onPairAnother, + onRevoke, + revokingDeviceIds +}: HeroPairedProps): React.JSX.Element { + return ( +
+
+ + {translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')} + +
+

+ {devices.length === 1 + ? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.') + : translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')} +

+

+ {translate( + 'auto.components.mobile.MobileHero.266c18c105', + 'Open Orca Mobile to pick up where you left off, or pair another device.' + )} +

+
    + {devices.map((device) => { + const revoking = revokingDeviceIds.includes(device.deviceId) + return ( +
  • +
    + +
    +
    +
    {device.name}
    +
    + {translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '} + {new Date(device.pairedAt).toLocaleDateString()} +
    +
    + +
  • + ) + })} +
+
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/onboarding/ThemeStep.tsx b/src/renderer/src/components/onboarding/ThemeStep.tsx index ad4217aca52..afb0bded49a 100644 --- a/src/renderer/src/components/onboarding/ThemeStep.tsx +++ b/src/renderer/src/components/onboarding/ThemeStep.tsx @@ -11,6 +11,7 @@ import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' +import { ChromePreview } from './theme-chrome-preview' type ThemeStepProps = { theme: GlobalSettings['theme'] @@ -273,78 +274,6 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th ) } -function ChromePreview({ variant }: { variant: GlobalSettings['theme'] }) { - if (variant === 'system') { - return ( -
-
- -
-
- -
-
-
- ) - } - return -} - -function ChromeMock({ dark }: { dark: boolean }) { - // Tiny Orca chrome: sidebar with two rows + a content area with a tab and - // a composer line. Pure Tailwind so it stays lightweight inside the tile. - const bg = dark ? 'bg-[#0f1115]' : 'bg-[#f7f8fa]' - const sidebar = dark ? 'bg-[#16181d]' : 'bg-[#eceef2]' - const sidebarBorder = dark ? 'border-white/5' : 'border-black/5' - const row = dark ? 'bg-white/10' : 'bg-black/10' - const rowDim = dark ? 'bg-white/5' : 'bg-black/5' - const tab = dark ? 'bg-[#1d2026] border-white/5' : 'bg-white border-black/5' - const accent = 'bg-violet-500/80' - return ( -
-
-
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
- - -
-
-
- ) -} - function humanFields(diff: Partial): string[] { // Why: chip labels are a friendly summary, not a strict 1:1 of mapper keys. // Group related diff keys (font weight + family + size → "Font") so the row diff --git a/src/renderer/src/components/onboarding/theme-chrome-preview.tsx b/src/renderer/src/components/onboarding/theme-chrome-preview.tsx new file mode 100644 index 00000000000..68ef3120ef8 --- /dev/null +++ b/src/renderer/src/components/onboarding/theme-chrome-preview.tsx @@ -0,0 +1,74 @@ +import { cn } from '@/lib/utils' +import type { GlobalSettings } from '../../../../shared/types' + +export function ChromePreview({ variant }: { variant: GlobalSettings['theme'] }) { + if (variant === 'system') { + return ( +
+
+ +
+
+ +
+
+
+ ) + } + return +} + +function ChromeMock({ dark }: { dark: boolean }) { + // Tiny Orca chrome: sidebar with two rows + a content area with a tab and + // a composer line. Pure Tailwind so it stays lightweight inside the tile. + const bg = dark ? 'bg-[#0f1115]' : 'bg-[#f7f8fa]' + const sidebar = dark ? 'bg-[#16181d]' : 'bg-[#eceef2]' + const sidebarBorder = dark ? 'border-white/5' : 'border-black/5' + const row = dark ? 'bg-white/10' : 'bg-black/10' + const rowDim = dark ? 'bg-white/5' : 'bg-black/5' + const tab = dark ? 'bg-[#1d2026] border-white/5' : 'bg-white border-black/5' + const accent = 'bg-violet-500/80' + return ( +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx index af2a2c546c2..1f5e324ab74 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx @@ -19,15 +19,24 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ }) =>
{children}
})) -function renderHeader(canUnlinkPullRequest = true): string { +function renderHeader({ + canUnlinkPullRequest = true, + provider = 'github' +}: { + canUnlinkPullRequest?: boolean + provider?: 'github' | 'gitlab' +} = {}): string { + const isGitLab = provider === 'gitlab' return renderToStaticMarkup( { }) it('disables unlinking when the displayed PR is not manually linked', () => { - const markup = renderHeader(false) + const markup = renderHeader({ canUnlinkPullRequest: false }) expect(markup).toContain('data-disabled="true"') expect(markup).toContain('unlink PR') }) + + it('shows GitLab MR unlink actions in the menu', () => { + const markup = renderHeader({ provider: 'gitlab' }) + + expect(markup).toContain('Open on GitLab') + expect(markup).toContain('!31') + expect(markup).toContain('More MR actions') + expect(markup).toContain('unlink MR') + expect(markup).not.toContain('Link another PR') + }) }) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index ed51d3fdf6b..38c89f37d40 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -94,9 +94,12 @@ import { shouldShowChecksPanelPublishBranchAction } from './checks-panel-empty-state' import { + cancelRuntimeGeneratePullRequestFields, + generateRuntimePullRequestFields, getRuntimeGitScope, getRuntimeGitStatus, - getRuntimeGitUpstreamStatus + getRuntimeGitUpstreamStatus, + type RuntimeGeneratePullRequestFieldsOverrides } from '@/runtime/runtime-git-client' import { buildChecksPanelGitStatusContextKey, @@ -134,6 +137,21 @@ import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch- import { CreateHostedReviewComposer } from './CreateHostedReviewComposer' import { formatCreateError } from './create-pull-request-review-copy' import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' +import { + resolveChecksPanelHostedReviewBaseRef, + shouldOpenChecksPanelCreateComposer +} from './checks-panel-review-creation' +import { + createRunningPullRequestGenerationRecord, + getPullRequestGenerationRecordKey, + resolvePullRequestGenerationCancel, + resolvePullRequestGenerationFailure, + resolvePullRequestGenerationSuccess, + shouldHydratePullRequestGenerationResult, + type PullRequestFieldRevisions, + type PullRequestGenerationContext, + type PullRequestGenerationFields +} from '@/store/slices/pull-request-generation' import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy' import { translate } from '@/i18n/i18n' import { groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups' @@ -184,7 +202,8 @@ export function ChecksPanelReviewHeader({ const reviewNumberLabel = review.provider === 'gitlab' ? `!${review.number}` : `#${review.number}` const ReviewIcon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon const reviewHostLabel = review.provider === 'gitlab' ? 'GitLab' : 'GitHub' - const showPullRequestMenu = review.provider === 'github' + const showPullRequestMenu = review.provider === 'github' || review.provider === 'gitlab' + const shortReviewLabel = review.provider === 'gitlab' ? 'MR' : 'PR' return (
@@ -226,12 +245,14 @@ export function ChecksPanelReviewHeader({ variant="ghost" size="icon-xs" aria-label={translate( - 'auto.components.right.sidebar.ChecksPanel.653c105ecc', - 'More PR actions' + 'auto.components.right.sidebar.ChecksPanel.b4f3ec62a1', + 'More {{value0}} actions', + { value0: shortReviewLabel } )} title={translate( - 'auto.components.right.sidebar.ChecksPanel.653c105ecc', - 'More PR actions' + 'auto.components.right.sidebar.ChecksPanel.b4f3ec62a1', + 'More {{value0}} actions', + { value0: shortReviewLabel } )} className="text-muted-foreground hover:text-foreground" > @@ -241,12 +262,21 @@ export function ChecksPanelReviewHeader({ - {translate('auto.components.right.sidebar.ChecksPanel.7202f4a40a', 'unlink PR')} - - - - {translate('auto.components.right.sidebar.ChecksPanel.07871c0589', 'Link another PR')} + {translate( + 'auto.components.right.sidebar.ChecksPanel.a9d7c128e4', + 'unlink {{value0}}', + { value0: shortReviewLabel } + )} + {review.provider === 'github' ? ( + + + {translate( + 'auto.components.right.sidebar.ChecksPanel.07871c0589', + 'Link another PR' + )} + + ) : null} )} @@ -344,6 +374,12 @@ export default function ChecksPanel(): React.JSX.Element { (s) => s.getHostedReviewCreationEligibility ) const createHostedReview = useAppStore((s) => s.createHostedReview) + const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords) + const allocatePullRequestGenerationRequestId = useAppStore( + (s) => s.allocatePullRequestGenerationRequestId + ) + const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord) + const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord) const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh) const conflictOperation = useAppStore((s) => activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown' @@ -461,6 +497,25 @@ export default function ChecksPanel(): React.JSX.Element { const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' const activeWorktreePath = activeWorktree?.path ?? null const activeWorktreePushTarget = activeWorktree?.pushTarget ?? null + const hostedReviewCreationBaseRef = resolveChecksPanelHostedReviewBaseRef({ + worktreeBaseRef: activeWorktree?.baseRef, + repoBaseRef: repo?.worktreeBaseRef + }) + const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({ + worktreeId: activeWorktreeId, + worktreePath: activeWorktreePath, + repoId: repo?.id, + branch + }) + const activePullRequestGenerationRecordCandidate = activePullRequestGenerationKey + ? (prGenerationRecords[activePullRequestGenerationKey] ?? null) + : null + const activePullRequestGenerationRecord = + activePullRequestGenerationRecordCandidate && + activePullRequestGenerationRecordCandidate.context.repoId === repo?.id && + activePullRequestGenerationRecordCandidate.context.branch === branch + ? activePullRequestGenerationRecordCandidate + : null const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({ connectionId: activeConnectionId, worktreePath: activeWorktreePath @@ -619,7 +674,7 @@ export default function ChecksPanel(): React.JSX.Element { runtimeEnvironmentId, connectionId: repoConnectionId, branch, - base: repo.worktreeBaseRef ?? null, + base: hostedReviewCreationBaseRef, hasUncommittedChanges: gitStatusSnapshot?.contextKey === panelContextKey ? gitStatusSnapshot.hasUncommittedChanges @@ -674,6 +729,141 @@ export default function ChecksPanel(): React.JSX.Element { const connectionId = activeConnectionId ?? undefined await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId) }, [activeConnectionId, activeWorktree?.path, activeWorktreeId, fetchUpstreamStatus]) + const handleGeneratePullRequestFieldsForActive = useCallback( + async ( + fields: PullRequestGenerationFields, + fieldRevisions: PullRequestFieldRevisions, + overrides?: RuntimeGeneratePullRequestFieldsOverrides + ): Promise => { + if (!repo || !activePullRequestGenerationKey || !activeWorktreePath || !branch) { + return + } + const generationKey = activePullRequestGenerationKey + if ( + useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running' + ) { + return + } + const requestId = allocatePullRequestGenerationRequestId() + const context: PullRequestGenerationContext = { + worktreeId: activeWorktreeId, + worktreePath: activeWorktreePath, + connectionId: activeConnectionId ?? undefined, + requestId, + repoId: repo.id, + branch + } + const seed = { ...fields } + // Why: Checks stays mounted across tab switches, but the composer itself + // can unmount when eligibility refreshes; keep generation tied to the branch. + setPullRequestGenerationRecord( + generationKey, + createRunningPullRequestGenerationRecord(context, seed, fieldRevisions) + ) + + try { + const result = await generateRuntimePullRequestFields( + { + settings: useAppStore.getState().settings, + worktreeId: context.worktreeId, + worktreePath: context.worktreePath, + connectionId: context.connectionId + }, + { + base: stripBaseRef((seed.base ?? '').trim()), + title: seed.title, + body: seed.body, + draft: seed.draft + }, + overrides + ) + if (result.branchChangedByPreparation) { + await handleBranchChangedByPullRequestGeneration() + } + if (result.success) { + useAppStore.getState().recordFeatureInteraction('ai-pr-generation') + } + updatePullRequestGenerationRecord(generationKey, (record) => { + if (!result.success) { + return resolvePullRequestGenerationFailure({ + record, + requestId, + canceled: result.canceled, + error: result.canceled ? null : result.error + }) + } + if (!record) { + return null + } + return resolvePullRequestGenerationSuccess({ + record, + requestId, + result: { + base: stripBaseRef(result.fields.base), + title: result.fields.title, + body: result.fields.body, + draft: result.fields.draft + } + }) + }) + } catch (error) { + updatePullRequestGenerationRecord(generationKey, (record) => + resolvePullRequestGenerationFailure({ + record, + requestId, + error: + error instanceof Error ? error.message : 'Failed to generate pull request details' + }) + ) + } + }, + [ + activeConnectionId, + activePullRequestGenerationKey, + activeWorktreeId, + activeWorktreePath, + allocatePullRequestGenerationRequestId, + branch, + handleBranchChangedByPullRequestGeneration, + repo, + setPullRequestGenerationRecord, + updatePullRequestGenerationRecord + ] + ) + const handleCancelGeneratePullRequestFieldsForActive = useCallback((): void => { + if (!activePullRequestGenerationKey) { + return + } + const record = prGenerationRecords[activePullRequestGenerationKey] + if (!record || record.status !== 'running') { + return + } + const generationKey = activePullRequestGenerationKey + updatePullRequestGenerationRecord(generationKey, (current) => { + if (!current || current.context.requestId !== record.context.requestId) { + return null + } + return resolvePullRequestGenerationCancel(current) + }) + void cancelRuntimeGeneratePullRequestFields({ + settings: useAppStore.getState().settings, + worktreeId: record.context.worktreeId, + worktreePath: record.context.worktreePath, + connectionId: record.context.connectionId + }).catch((error) => { + updatePullRequestGenerationRecord(generationKey, (current) => { + if (!current || current.context.requestId !== record.context.requestId) { + return null + } + return { + ...current, + status: 'failed', + error: error instanceof Error ? error.message : 'Failed to stop pull request generation', + hydrated: false + } + }) + }) + }, [activePullRequestGenerationKey, prGenerationRecords, updatePullRequestGenerationRecord]) const prCreationDefaults = useMemo(() => { if (!settings) { return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS @@ -696,12 +886,12 @@ export default function ChecksPanel(): React.JSX.Element { prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS }) }, [repo, settings]) - const createComposerOpen = - !activeReview && - !isFolder && - Boolean(branch) && - (hostedReviewCreation?.canCreate === true || - hostedReviewCreation?.blockedReason === 'needs_push') + const createComposerOpen = shouldOpenChecksPanelCreateComposer({ + activeReview, + isFolder, + branch, + hostedReviewCreation + }) const { aiGenerationEnabled: prAiGenerationEnabled, base: prBase, @@ -722,7 +912,8 @@ export default function ChecksPanel(): React.JSX.Element { generateDisabled: prGenerateDisabled, generateDisabledReason: prGenerateDisabledReason, handleGenerate: handleGeneratePullRequestFields, - handleCancelGenerate: handleCancelGeneratePullRequestFields + handleCancelGenerate: handleCancelGeneratePullRequestFields, + applyGeneratedFields: applyGeneratedPullRequestFields } = useCreatePullRequestDialogFields({ open: createComposerOpen, repoId: repo?.id ?? '', @@ -734,8 +925,57 @@ export default function ChecksPanel(): React.JSX.Element { settings, submitting: isCreatingPr, prCreationDefaults, - onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration + onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration, + generation: { + generating: activePullRequestGenerationRecord?.status === 'running', + generateError: activePullRequestGenerationRecord?.error ?? null, + onGenerate: (fields, fieldRevisions, overrides) => { + void handleGeneratePullRequestFieldsForActive(fields, fieldRevisions, overrides) + }, + onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive + } }) + + useEffect(() => { + if ( + !activePullRequestGenerationKey || + !activePullRequestGenerationRecord || + activePullRequestGenerationRecord.status !== 'succeeded' || + !activePullRequestGenerationRecord.result || + activePullRequestGenerationRecord.hydrated + ) { + return + } + if ( + !shouldHydratePullRequestGenerationResult({ + record: activePullRequestGenerationRecord + }) + ) { + return + } + applyGeneratedPullRequestFields( + activePullRequestGenerationRecord.result, + activePullRequestGenerationRecord.seedFieldRevisions + ) + updatePullRequestGenerationRecord(activePullRequestGenerationKey, (record) => { + if ( + !record || + record.context.requestId !== activePullRequestGenerationRecord.context.requestId + ) { + return null + } + return { + ...record, + hydrated: true + } + }) + }, [ + activePullRequestGenerationKey, + activePullRequestGenerationRecord, + applyGeneratedPullRequestFields, + updatePullRequestGenerationRecord + ]) + const handlePrBaseChange = useCallback( (value: string): void => { setCreatePrError(null) @@ -985,7 +1225,7 @@ export default function ChecksPanel(): React.JSX.Element { repoPath: repo.path, ...(activeWorktreePath ? { worktreePath: activeWorktreePath } : {}), branch, - base: repo.worktreeBaseRef ?? null, + base: hostedReviewCreationBaseRef, hasUncommittedChanges, hasUpstream: remoteStatus?.hasUpstream, ahead: remoteStatus?.ahead, @@ -1023,6 +1263,7 @@ export default function ChecksPanel(): React.JSX.Element { getHostedReviewCreationEligibility, gitStatusReadyForPanelContext, hasUncommittedChanges, + hostedReviewCreationBaseRef, hostedReviewCreationRequestKey, isFolder, isPanelVisible, @@ -2466,11 +2707,17 @@ export default function ChecksPanel(): React.JSX.Element { }, [activeReview, activeWorktreeId]) const handleUnlinkPullRequest = useCallback(() => { - if (!activeWorktreeId || activeReview?.provider !== 'github' || linkedPR === null) { + if (!activeWorktreeId || !activeReview) { return } - void updateWorktreeMeta(activeWorktreeId, { linkedPR: null }) - }, [activeReview?.provider, activeWorktreeId, linkedPR, updateWorktreeMeta]) + if (activeReview.provider === 'github' && linkedPR !== null) { + void updateWorktreeMeta(activeWorktreeId, { linkedPR: null }) + return + } + if (activeReview.provider === 'gitlab' && linkedGitLabMR !== null) { + void updateWorktreeMeta(activeWorktreeId, { linkedGitLabMR: null }) + } + }, [activeReview, activeWorktreeId, linkedGitLabMR, linkedPR, updateWorktreeMeta]) const handleLinkAnotherPullRequest = useCallback(() => { if (!activeWorktreeId || !activeWorktree || activeReview?.provider !== 'github') { @@ -2951,7 +3198,9 @@ export default function ChecksPanel(): React.JSX.Element { void handleRefresh()} onOpenReview={handleOpenPR} onUnlinkPullRequest={handleUnlinkPullRequest} diff --git a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx index ff88e8545c2..b82419fb61b 100644 --- a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx +++ b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx @@ -1,6 +1,5 @@ -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useMemo } from 'react' import { LoaderCircle, GitMerge, ChevronDown, GitPullRequestClosed } from 'lucide-react' -import { toast } from 'sonner' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' @@ -12,11 +11,8 @@ import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' -import { useConfirmationDialog } from '@/components/confirmation-dialog' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' -import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { PRInfo, Repo, Worktree } from '../../../../shared/types' -import type { GitHubPRMergeMethod } from '../../../../shared/types' import { resolveGitHubPRMergeMethods } from '../../../../shared/github-pr-merge-methods' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { presentGitLabMRMergeState } from './gitlab-mr-merge-state' @@ -25,19 +21,9 @@ import { HostedReviewActionError, MergedReviewActions } from './HostedReviewStateActions' +import { useHostedReviewActions, type HostedReviewActionInfo } from './use-hosted-review-actions' import { translate } from '@/i18n/i18n' -type HostedReviewActionInfo = Pick< - HostedReviewInfo, - 'provider' | 'number' | 'state' | 'status' | 'mergeable' -> & - Partial< - Pick< - HostedReviewInfo, - 'reviewDecision' | 'autoMergeEnabled' | 'mergeQueueRequired' | 'mergeStateStatus' - > - > - export default function HostedReviewActions({ review, githubPR, @@ -54,11 +40,6 @@ export default function HostedReviewActions({ const isDeletingWorktree = useAppStore( (s) => s.deleteStateByWorktreeId[worktree.id]?.isDeleting ?? false ) - const confirm = useConfirmationDialog() - const [merging, setMerging] = useState(false) - const [stateUpdating, setStateUpdating] = useState<'open' | 'closed' | null>(null) - const [actionError, setActionError] = useState(null) - const isGitLab = review.provider === 'gitlab' const shortLabel = isGitLab ? 'MR' : 'PR' const reviewLabel = isGitLab ? 'merge request' : 'pull request' @@ -81,6 +62,25 @@ export default function HostedReviewActions({ () => resolveGitHubPRMergeMethods(isGitLab ? null : (githubPR?.mergeMethodSettings ?? null)), [githubPR?.mergeMethodSettings, isGitLab] ) + const { + merging, + stateUpdating, + actionError, + handleMerge, + handleAutoMerge, + handleCloseReview, + handleReopenReview + } = useHostedReviewActions({ + review, + githubPR, + repo, + isGitLab, + shortLabel, + reviewLabel, + defaultMergeMethod: mergeMethods.defaultMethod, + autoMergeAction: mergePresentation.autoMergeAction, + onRefreshReview + }) const isUpdatingReviewState = stateUpdating !== null const primaryMergeDisabled = merging || @@ -90,169 +90,6 @@ export default function HostedReviewActions({ merging || isUpdatingReviewState || !mergePresentation.directMergeAvailable const menuDisabled = merging || isUpdatingReviewState - const handleMerge = useCallback( - async (method: GitHubPRMergeMethod = mergeMethods.defaultMethod) => { - setMerging(true) - setActionError(null) - try { - const result = isGitLab - ? await window.api.gl.mergeMR({ - repoPath: repo.path, - iid: review.number, - method - }) - : await window.api.gh.mergePR({ - repoPath: repo.path, - repoId: repo.id, - prNumber: review.number, - method, - prRepo: githubPR?.prRepo ?? null - }) - if (!result.ok) { - setActionError(result.error) - } else { - await onRefreshReview() - } - } catch (err) { - setActionError(err instanceof Error ? err.message : 'Merge failed') - } finally { - setMerging(false) - } - }, - [ - githubPR?.prRepo, - isGitLab, - mergeMethods.defaultMethod, - onRefreshReview, - repo.id, - repo.path, - review.number - ] - ) - - const handleAutoMerge = useCallback(async () => { - if (isGitLab || !mergePresentation.autoMergeAction) { - return - } - const enabled = mergePresentation.autoMergeAction.kind === 'enable' - setMerging(true) - setActionError(null) - try { - const result = await window.api.gh.setPRAutoMerge({ - repoPath: repo.path, - repoId: repo.id, - prNumber: review.number, - enabled, - prRepo: githubPR?.prRepo ?? null - }) - if (!result.ok) { - setActionError(result.error) - } else { - await onRefreshReview() - } - } catch (err) { - setActionError(err instanceof Error ? err.message : 'Auto-merge update failed') - } finally { - setMerging(false) - } - }, [ - githubPR?.prRepo, - isGitLab, - mergePresentation.autoMergeAction, - onRefreshReview, - repo.id, - repo.path, - review.number - ]) - - const handleReviewStateChange = useCallback( - async (nextState: 'open' | 'closed') => { - if (stateUpdating) { - return - } - const isClosing = nextState === 'closed' - const label = isClosing ? 'Close' : 'Reopen' - const confirmed = await confirm({ - title: `${label} ${shortLabel} ${isGitLab ? '!' : '#'}${review.number}?`, - description: isClosing - ? translate( - 'auto.components.right.sidebar.HostedReviewActions.a3d572a4de', - 'This will close the {{value0}}.', - { value0: reviewLabel } - ) - : translate( - 'auto.components.right.sidebar.HostedReviewActions.78f5ff294c', - 'This will reopen the {{value0}}.', - { value0: reviewLabel } - ), - confirmLabel: label, - confirmVariant: isClosing ? 'destructive' : 'default' - }) - if (!confirmed) { - return - } - setStateUpdating(nextState) - setActionError(null) - try { - const result = isGitLab - ? isClosing - ? await window.api.gl.closeMR({ repoPath: repo.path, iid: review.number }) - : await window.api.gl.reopenMR({ repoPath: repo.path, iid: review.number }) - : await window.api.gh.updatePRState({ - repoPath: repo.path, - repoId: repo.id, - prNumber: review.number, - updates: { state: nextState } - }) - if (!result.ok) { - setActionError(result.error) - toast.error(result.error) - } else { - toast.success( - isClosing - ? translate( - 'auto.components.right.sidebar.HostedReviewActions.fa3ee9a515', - '{{value0}} closed', - { value0: shortLabel } - ) - : translate( - 'auto.components.right.sidebar.HostedReviewActions.377269db6f', - '{{value0}} reopened', - { value0: shortLabel } - ) - ) - await onRefreshReview() - } - } catch (err) { - const message = - err instanceof Error ? err.message : `Failed to ${label.toLowerCase()} ${reviewLabel}` - setActionError(message) - toast.error(message) - } finally { - setStateUpdating(null) - } - }, - [ - confirm, - isGitLab, - onRefreshReview, - repo.id, - repo.path, - review.number, - reviewLabel, - shortLabel, - stateUpdating - ] - ) - - const handleCloseReview = useCallback(async () => { - await handleReviewStateChange('closed') - }, [handleReviewStateChange]) - - const handleReopenReview = useCallback(async () => { - await handleReviewStateChange('open') - }, [handleReviewStateChange]) - const handleDeleteWorktree = useCallback(() => { // Why: route every UI delete entry point through the shared funnel so // skip-confirm, main-worktree, and child-workspace safeguards cannot drift. @@ -389,7 +226,6 @@ export default function HostedReviewActions({ /> ) } - if (review.state === 'merged') { return ( { + it('prefers the worktree base ref over the repo default', () => { + expect( + resolveChecksPanelHostedReviewBaseRef({ + worktreeBaseRef: ' release/1.4 ', + repoBaseRef: 'main' + }) + ).toBe('release/1.4') + }) + + it('falls back to the repo base ref when the worktree has no override', () => { + expect( + resolveChecksPanelHostedReviewBaseRef({ + worktreeBaseRef: null, + repoBaseRef: ' main ' + }) + ).toBe('main') + }) + + it('returns null when both inputs are null', () => { + expect( + resolveChecksPanelHostedReviewBaseRef({ + worktreeBaseRef: null, + repoBaseRef: null + }) + ).toBe(null) + }) + + it('returns null when worktree base ref is whitespace-only', () => { + expect( + resolveChecksPanelHostedReviewBaseRef({ + worktreeBaseRef: ' ', + repoBaseRef: null + }) + ).toBe(null) + }) + + it('strips origin prefix from the worktree base ref', () => { + expect( + resolveChecksPanelHostedReviewBaseRef({ + worktreeBaseRef: 'origin/main', + repoBaseRef: 'develop' + }) + ).toBe('main') + }) + + it('strips upstream prefix from the repo base ref', () => { + expect( + resolveChecksPanelHostedReviewBaseRef({ + worktreeBaseRef: null, + repoBaseRef: 'upstream/develop' + }) + ).toBe('develop') + }) +}) + +describe('shouldOpenChecksPanelCreateComposer', () => { + it('opens for GitLab MR creation eligibility', () => { + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/gitlab-mr', + hostedReviewCreation: { + provider: 'gitlab', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null + } + }) + ).toBe(true) + }) + + it('opens for push-before-create recovery', () => { + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/gitlab-mr', + hostedReviewCreation: { + provider: 'gitlab', + review: null, + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push' + } + }) + ).toBe(true) + }) + + it('does not open when an active review exists', () => { + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: { provider: 'github', number: 123 }, + isFolder: false, + branch: 'feature/test', + hostedReviewCreation: { + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null + } + }) + ).toBe(false) + }) + + it('does not open for folder repos', () => { + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: true, + branch: 'feature/test', + hostedReviewCreation: { + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null + } + }) + ).toBe(false) + }) + + it('does not open when branch is empty', () => { + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: '', + hostedReviewCreation: { + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null + } + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts new file mode 100644 index 00000000000..11e42d40fe1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts @@ -0,0 +1,30 @@ +import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' +import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs' + +export function resolveChecksPanelHostedReviewBaseRef(input: { + worktreeBaseRef?: string | null + repoBaseRef?: string | null +}): string | null { + const worktreeBaseRef = normalizeChecksPanelHostedReviewBaseRef(input.worktreeBaseRef) + return worktreeBaseRef || normalizeChecksPanelHostedReviewBaseRef(input.repoBaseRef) +} + +function normalizeChecksPanelHostedReviewBaseRef(ref: string | null | undefined): string | null { + const normalizedRef = ref ? normalizeHostedReviewBaseRef(ref) : '' + return normalizedRef || null +} + +export function shouldOpenChecksPanelCreateComposer(input: { + activeReview: unknown | null + isFolder: boolean + branch: string + hostedReviewCreation: HostedReviewCreationEligibility | null +}): boolean { + return ( + !input.activeReview && + !input.isFolder && + Boolean(input.branch) && + (input.hostedReviewCreation?.canCreate === true || + input.hostedReviewCreation?.blockedReason === 'needs_push') + ) +} diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts new file mode 100644 index 00000000000..525d2460f7f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts @@ -0,0 +1,227 @@ +import { useCallback, useState } from 'react' +import { toast } from 'sonner' +import { useConfirmationDialog } from '@/components/confirmation-dialog' +import type { GitHubPRAutoMergeAction } from '@/components/github-pr-merge-state' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { PRInfo, Repo } from '../../../../shared/types' +import type { GitHubPRMergeMethod } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export type HostedReviewActionInfo = Pick< + HostedReviewInfo, + 'provider' | 'number' | 'state' | 'status' | 'mergeable' +> & + Partial< + Pick< + HostedReviewInfo, + 'reviewDecision' | 'autoMergeEnabled' | 'mergeQueueRequired' | 'mergeStateStatus' + > + > + +export function useHostedReviewActions({ + review, + githubPR, + repo, + isGitLab, + shortLabel, + reviewLabel, + defaultMergeMethod, + autoMergeAction, + onRefreshReview +}: { + review: HostedReviewActionInfo + githubPR?: PRInfo | null + repo: Repo + isGitLab: boolean + shortLabel: string + reviewLabel: string + defaultMergeMethod: GitHubPRMergeMethod + autoMergeAction: GitHubPRAutoMergeAction | null + onRefreshReview: () => Promise +}): { + merging: boolean + stateUpdating: 'open' | 'closed' | null + actionError: string | null + handleMerge: (method?: GitHubPRMergeMethod) => Promise + handleAutoMerge: () => Promise + handleCloseReview: () => Promise + handleReopenReview: () => Promise +} { + const confirm = useConfirmationDialog() + const [merging, setMerging] = useState(false) + const [stateUpdating, setStateUpdating] = useState<'open' | 'closed' | null>(null) + const [actionError, setActionError] = useState(null) + + const handleMerge = useCallback( + async (method: GitHubPRMergeMethod = defaultMergeMethod) => { + setMerging(true) + setActionError(null) + try { + const result = isGitLab + ? await window.api.gl.mergeMR({ + repoPath: repo.path, + iid: review.number, + method + }) + : await window.api.gh.mergePR({ + repoPath: repo.path, + repoId: repo.id, + prNumber: review.number, + method, + prRepo: githubPR?.prRepo ?? null + }) + if (!result.ok) { + setActionError(result.error) + } else { + await onRefreshReview() + } + } catch (err) { + setActionError(err instanceof Error ? err.message : 'Merge failed') + } finally { + setMerging(false) + } + }, + [ + githubPR?.prRepo, + isGitLab, + defaultMergeMethod, + onRefreshReview, + repo.id, + repo.path, + review.number + ] + ) + + const handleAutoMerge = useCallback(async () => { + if (isGitLab || !autoMergeAction) { + return + } + const enabled = autoMergeAction.kind === 'enable' + setMerging(true) + setActionError(null) + try { + const result = await window.api.gh.setPRAutoMerge({ + repoPath: repo.path, + repoId: repo.id, + prNumber: review.number, + enabled, + prRepo: githubPR?.prRepo ?? null + }) + if (!result.ok) { + setActionError(result.error) + } else { + await onRefreshReview() + } + } catch (err) { + setActionError(err instanceof Error ? err.message : 'Auto-merge update failed') + } finally { + setMerging(false) + } + }, [ + githubPR?.prRepo, + isGitLab, + autoMergeAction, + onRefreshReview, + repo.id, + repo.path, + review.number + ]) + + const handleReviewStateChange = useCallback( + async (nextState: 'open' | 'closed') => { + if (stateUpdating) { + return + } + const isClosing = nextState === 'closed' + const label = isClosing ? 'Close' : 'Reopen' + const confirmed = await confirm({ + title: `${label} ${shortLabel} ${isGitLab ? '!' : '#'}${review.number}?`, + description: isClosing + ? translate( + 'auto.components.right.sidebar.HostedReviewActions.a3d572a4de', + 'This will close the {{value0}}.', + { value0: reviewLabel } + ) + : translate( + 'auto.components.right.sidebar.HostedReviewActions.78f5ff294c', + 'This will reopen the {{value0}}.', + { value0: reviewLabel } + ), + confirmLabel: label, + confirmVariant: isClosing ? 'destructive' : 'default' + }) + if (!confirmed) { + return + } + setStateUpdating(nextState) + setActionError(null) + try { + const result = isGitLab + ? isClosing + ? await window.api.gl.closeMR({ repoPath: repo.path, iid: review.number }) + : await window.api.gl.reopenMR({ repoPath: repo.path, iid: review.number }) + : await window.api.gh.updatePRState({ + repoPath: repo.path, + repoId: repo.id, + prNumber: review.number, + updates: { state: nextState } + }) + if (!result.ok) { + setActionError(result.error) + toast.error(result.error) + } else { + toast.success( + isClosing + ? translate( + 'auto.components.right.sidebar.HostedReviewActions.fa3ee9a515', + '{{value0}} closed', + { value0: shortLabel } + ) + : translate( + 'auto.components.right.sidebar.HostedReviewActions.377269db6f', + '{{value0}} reopened', + { value0: shortLabel } + ) + ) + await onRefreshReview() + } + } catch (err) { + const message = + err instanceof Error ? err.message : `Failed to ${label.toLowerCase()} ${reviewLabel}` + setActionError(message) + toast.error(message) + } finally { + setStateUpdating(null) + } + }, + [ + confirm, + isGitLab, + onRefreshReview, + repo.id, + repo.path, + review.number, + reviewLabel, + shortLabel, + stateUpdating + ] + ) + + const handleCloseReview = useCallback(async () => { + await handleReviewStateChange('closed') + }, [handleReviewStateChange]) + + const handleReopenReview = useCallback(async () => { + await handleReviewStateChange('open') + }, [handleReviewStateChange]) + + return { + merging, + stateUpdating, + actionError, + handleMerge, + handleAutoMerge, + handleCloseReview, + handleReopenReview + } +} diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx index 541233c283c..8e775a5c69b 100644 --- a/src/renderer/src/components/settings/McpConfigSection.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.tsx @@ -6,13 +6,9 @@ import type { Repo, Worktree } from '../../../../shared/types' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' import { canInspectLocalMcpConfigRoot, - getMcpConfigCandidateParentDir, - getMcpConfigParentDirs, inspectMcpConfigContent, MCP_CONFIG_CANDIDATES, - MCP_STARTER_CONFIG, - selectExistingMcpConfigCandidates, - type McpConfigDirectoryEntry + MCP_STARTER_CONFIG } from '../../../../shared/mcp-config' import { useAppStore } from '../../store' import { joinPath } from '../../lib/path' @@ -21,6 +17,7 @@ import { Button } from '../ui/button' import { isWindowsUserAgent } from '../terminal-pane/pane-helpers' import { McpConfigFileRow, type LoadedMcpConfigInspection } from './McpConfigFileRow' import { McpMissingConfigList } from './McpMissingConfigList' +import { loadMcpConfigInspections } from './mcp-config-inspection' import { translate } from '@/i18n/i18n' type McpConfigSectionProps = { @@ -29,11 +26,6 @@ type McpConfigSectionProps = { const EMPTY_WORKTREES: Worktree[] = [] -function isMissingFileError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return /ENOENT|no such file|not found/i.test(message) -} - function countServers(configs: LoadedMcpConfigInspection[]): number { return configs.reduce((sum, config) => sum + config.servers.length, 0) } @@ -139,81 +131,7 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele return } - const entriesByRelativeDir = new Map() - const rootEntries = await window.api.fs.readDir({ dirPath: targetRootPath, connectionId }) - entriesByRelativeDir.set('', rootEntries) - - const rootDirectoryNames = new Set( - rootEntries.filter((entry) => entry.isDirectory).map((entry) => entry.name) - ) - const unreadableParentDirMessages = new Map() - await Promise.all( - getMcpConfigParentDirs().map(async (relativeDir) => { - if (!rootDirectoryNames.has(relativeDir)) { - return - } - try { - const entries = await window.api.fs.readDir({ - dirPath: joinPath(targetRootPath, relativeDir), - connectionId - }) - entriesByRelativeDir.set(relativeDir, entries) - } catch (error) { - unreadableParentDirMessages.set( - relativeDir, - extractIpcErrorMessage(error, `Unable to inspect ${relativeDir}.`) - ) - } - }) - ) - - const existingRelativePaths = new Set( - selectExistingMcpConfigCandidates(entriesByRelativeDir).map( - (candidate) => candidate.relativePath - ) - ) - - const next = await Promise.all( - MCP_CONFIG_CANDIDATES.map(async (candidate): Promise => { - const absolutePath = joinPath(targetRootPath, candidate.relativePath) - const parentDirReadError = unreadableParentDirMessages.get( - getMcpConfigCandidateParentDir(candidate) - ) - if (parentDirReadError) { - return { - ...inspectMcpConfigContent(candidate, null), - exists: false, - status: 'invalid', - absolutePath, - readError: parentDirReadError - } - } - - if (!existingRelativePaths.has(candidate.relativePath)) { - return { ...inspectMcpConfigContent(candidate, null), absolutePath } - } - - try { - const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId }) - const inspection = inspectMcpConfigContent( - candidate, - result.isBinary ? '' : result.content - ) - return { ...inspection, absolutePath } - } catch (error) { - if (isMissingFileError(error)) { - return { ...inspectMcpConfigContent(candidate, null), absolutePath } - } - return { - ...inspectMcpConfigContent(candidate, null), - exists: false, - status: 'invalid', - absolutePath, - readError: extractIpcErrorMessage(error, 'Unable to read config file.') - } - } - }) - ) + const next = await loadMcpConfigInspections(targetRootPath, connectionId) if (mountedRef.current) { setConfigs(next) } diff --git a/src/renderer/src/components/settings/mcp-config-inspection.ts b/src/renderer/src/components/settings/mcp-config-inspection.ts new file mode 100644 index 00000000000..50d5b59b1c7 --- /dev/null +++ b/src/renderer/src/components/settings/mcp-config-inspection.ts @@ -0,0 +1,94 @@ +import { + getMcpConfigCandidateParentDir, + getMcpConfigParentDirs, + inspectMcpConfigContent, + MCP_CONFIG_CANDIDATES, + selectExistingMcpConfigCandidates, + type McpConfigDirectoryEntry +} from '../../../../shared/mcp-config' +import { joinPath } from '../../lib/path' +import { extractIpcErrorMessage } from '../../lib/ipc-error' +import type { LoadedMcpConfigInspection } from './McpConfigFileRow' + +function isMissingFileError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /ENOENT|no such file|not found/i.test(message) +} + +export async function loadMcpConfigInspections( + targetRootPath: string, + connectionId: string | undefined +): Promise { + const entriesByRelativeDir = new Map() + const rootEntries = await window.api.fs.readDir({ dirPath: targetRootPath, connectionId }) + entriesByRelativeDir.set('', rootEntries) + + const rootDirectoryNames = new Set( + rootEntries.filter((entry) => entry.isDirectory).map((entry) => entry.name) + ) + const unreadableParentDirMessages = new Map() + await Promise.all( + getMcpConfigParentDirs().map(async (relativeDir) => { + if (!rootDirectoryNames.has(relativeDir)) { + return + } + try { + const entries = await window.api.fs.readDir({ + dirPath: joinPath(targetRootPath, relativeDir), + connectionId + }) + entriesByRelativeDir.set(relativeDir, entries) + } catch (error) { + unreadableParentDirMessages.set( + relativeDir, + extractIpcErrorMessage(error, `Unable to inspect ${relativeDir}.`) + ) + } + }) + ) + + const existingRelativePaths = new Set( + selectExistingMcpConfigCandidates(entriesByRelativeDir).map( + (candidate) => candidate.relativePath + ) + ) + + return Promise.all( + MCP_CONFIG_CANDIDATES.map(async (candidate): Promise => { + const absolutePath = joinPath(targetRootPath, candidate.relativePath) + const parentDirReadError = unreadableParentDirMessages.get( + getMcpConfigCandidateParentDir(candidate) + ) + if (parentDirReadError) { + return { + ...inspectMcpConfigContent(candidate, null), + exists: false, + status: 'invalid', + absolutePath, + readError: parentDirReadError + } + } + + if (!existingRelativePaths.has(candidate.relativePath)) { + return { ...inspectMcpConfigContent(candidate, null), absolutePath } + } + + try { + const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId }) + const inspection = inspectMcpConfigContent(candidate, result.isBinary ? '' : result.content) + return { ...inspection, absolutePath } + } catch (error) { + if (isMissingFileError(error)) { + return { ...inspectMcpConfigContent(candidate, null), absolutePath } + } + return { + ...inspectMcpConfigContent(candidate, null), + exists: false, + status: 'invalid', + absolutePath, + readError: extractIpcErrorMessage(error, 'Unable to read config file.') + } + } + }) + ) +} diff --git a/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx index 3ec00563c1c..7d92e5057cc 100644 --- a/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx +++ b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx @@ -232,6 +232,7 @@ export function FolderWorkspaceComposerDialog({ quickAgent, autoRenameBranchFromWork: settings?.autoRenameBranchFromWork, agentCmdOverrides: settings?.agentCmdOverrides, + isRemote: selectedRepoConnectionId !== null, createFolderWorkspace, onOpenChange }) @@ -246,6 +247,7 @@ export function FolderWorkspaceComposerDialog({ onOpenChange, projectGroup, quickAgent, + selectedRepoConnectionId, settings?.agentCmdOverrides, settings?.autoRenameBranchFromWork, submitting, diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 71c4a6d2ccd..0faef5ca7e7 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -32,6 +32,7 @@ type SubmitFolderWorkspaceCreateParams = { quickAgent: TuiAgent | null autoRenameBranchFromWork: boolean | undefined agentCmdOverrides: Record | undefined + isRemote?: boolean createFolderWorkspace: (input: FolderWorkspaceCreateInput) => Promise onOpenChange: (open: boolean) => void } @@ -45,6 +46,7 @@ export async function submitFolderWorkspaceCreate({ quickAgent, autoRenameBranchFromWork, agentCmdOverrides, + isRemote, createFolderWorkspace, onOpenChange }: SubmitFolderWorkspaceCreateParams): Promise { @@ -54,8 +56,10 @@ export async function submitFolderWorkspaceCreate({ nameIsAutoManaged && linkedName ? linkedName : name.trim() || linkedName || `${projectGroup.name} workspace` + // Why: only suggest `orca linear` when the launched terminal can actually + // resolve the CLI; SSH launches get the relay shim, local launches may not. const linearCliAvailable = linkedWorkItem?.linearIdentifier - ? await isOrcaCliAvailableForLaunch({ remote: projectGroup.connectionId != null }) + ? await isOrcaCliAvailableForLaunch({ remote: isRemote ?? projectGroup.connectionId != null }) : false const linkedPromptContext = getLinkedWorkItemPromptContext(linkedWorkItem, { cliAvailable: linearCliAvailable @@ -85,6 +89,7 @@ export async function submitFolderWorkspaceCreate({ if (!workspace) { return } + const startupPlan = quickAgent ? buildAgentStartupPlan({ agent: quickAgent, diff --git a/src/renderer/src/components/stats/ClaudeUsageDetails.tsx b/src/renderer/src/components/stats/ClaudeUsageDetails.tsx index 186616f323e..fc16182b759 100644 --- a/src/renderer/src/components/stats/ClaudeUsageDetails.tsx +++ b/src/renderer/src/components/stats/ClaudeUsageDetails.tsx @@ -5,33 +5,20 @@ import type { ClaudeUsageSummary } from '../../../../shared/claude-usage-types' import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart' +import { ClaudeUsageRecentSessionsTable } from './ClaudeUsageRecentSessionsTable' +import { UsageBreakdownSection } from './UsageBreakdownSection' import { translate } from '@/i18n/i18n' type ClaudeUsageDetailsProps = { daily: ClaudeUsageDailyPoint[] - formatTokens: (value: number) => string modelBreakdown: ClaudeUsageBreakdownRow[] projectBreakdown: ClaudeUsageBreakdownRow[] recentSessions: ClaudeUsageSessionRow[] summary: ClaudeUsageSummary | null | undefined } -function formatSessionTime(timestamp: string): string { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return timestamp - } - return parsed.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - }) -} - export function ClaudeUsageDetails({ daily, - formatTokens, modelBreakdown, projectBreakdown, recentSessions, @@ -42,131 +29,35 @@ export function ClaudeUsageDetails({
- ({ + key: row.key, + label: row.label, + tokens: row.inputTokens + row.outputTokens, + sessions: row.sessions, + eventsOrTurns: row.turns + }))} + eventsOrTurns="turns" /> - ({ + key: row.key, + label: row.label, + tokens: row.inputTokens + row.outputTokens, + sessions: row.sessions, + eventsOrTurns: row.turns + }))} + eventsOrTurns="turns" />
-
-
-

- {translate('auto.components.stats.ClaudeUsagePane.7e76c84153', 'Recent sessions')} -

-

- {translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '} - {summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined - ? `${Math.round(summary.cacheReuseRate * 100)}%` - : translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} -

-
-
- - - - - - - - - - - - - - {recentSessions.map((row) => ( - - - - - - - - - - ))} - -
- {translate('auto.components.stats.ClaudeUsagePane.01476891c7', 'Last active')} - - {translate('auto.components.stats.ClaudeUsagePane.c17bed0416', 'Project')} - - {translate('auto.components.stats.ClaudeUsagePane.1afc25eb06', 'Model')} - - {translate('auto.components.stats.ClaudeUsagePane.0f03975d59', 'Turns')} - - {translate('auto.components.stats.ClaudeUsagePane.faf3444859', 'Input')} - - {translate('auto.components.stats.ClaudeUsagePane.a8b7487ff7', 'Output')} - - {translate('auto.components.stats.ClaudeUsagePane.21ea00bfa8', 'Cache')} -
- {formatSessionTime(row.lastActiveAt)} - {row.projectLabel} - {row.model ?? - translate('auto.components.stats.ClaudeUsagePane.cfe2282ffa', 'Unknown')} - {row.turns} - {formatTokens(row.inputTokens)} - - {formatTokens(row.outputTokens)} - - {formatTokens(row.cacheReadTokens + row.cacheWriteTokens)} -
-
-
+ ) } - -function ClaudeUsageBreakdownSection({ - formatTokens, - label, - rows, - topLabel, - topValue -}: { - formatTokens: (value: number) => string - label: string - rows: ClaudeUsageBreakdownRow[] - topLabel: string - topValue: string | null | undefined -}): React.JSX.Element { - return ( -
-
-

{label}

-

- {topLabel}{' '} - {topValue ?? translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} -

-
-
- {rows.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.inputTokens + row.outputTokens)} - -
-
- {row.sessions}{' '} - {translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '} - {row.turns} {translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')} -
-
- ))} -
-
- ) -} diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx index 1f2377cd527..32536628f8d 100644 --- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx +++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx @@ -324,7 +324,6 @@ export function ClaudeUsagePane(): React.JSX.Element { string modelBreakdown: CodexUsageBreakdownRow[] projectBreakdown: CodexUsageBreakdownRow[] recentSessions: CodexUsageSessionRow[] summary: CodexUsageSummary | null | undefined } -function formatSessionTime(timestamp: string): string { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return timestamp - } - return parsed.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - }) -} - export function CodexUsageDetails({ daily, - formatTokens, modelBreakdown, projectBreakdown, recentSessions, @@ -42,142 +29,36 @@ export function CodexUsageDetails({
- ({ + key: row.key, + label: row.label, + tokens: row.totalTokens, + sessions: row.sessions, + eventsOrTurns: row.events, + hasInferredPricing: row.hasInferredPricing + }))} + eventsOrTurns="events" /> - ({ + key: row.key, + label: row.label, + tokens: row.totalTokens, + sessions: row.sessions, + eventsOrTurns: row.events + }))} + eventsOrTurns="events" />
-
-
-

- {translate('auto.components.stats.CodexUsagePane.0cb0983c07', 'Recent sessions')} -

-

- {translate( - 'auto.components.stats.CodexUsagePane.0bd8655475', - 'Most recent local Codex sessions in this scope.' - )} -

-
-
- - - - - - - - - - - - - - {recentSessions.map((row) => ( - - - - - - - - - - ))} - -
- {translate('auto.components.stats.CodexUsagePane.0c36b100be', 'Last active')} - - {translate('auto.components.stats.CodexUsagePane.1a65900aea', 'Project')} - - {translate('auto.components.stats.CodexUsagePane.c2478bcc3c', 'Model')} - - {translate('auto.components.stats.CodexUsagePane.bd0822ca47', 'Events')} - - {translate('auto.components.stats.CodexUsagePane.3acc582214', 'Input')} - - {translate('auto.components.stats.CodexUsagePane.bbd20344b8', 'Output')} - - {translate('auto.components.stats.CodexUsagePane.e0b988599d', 'Total')} -
- {formatSessionTime(row.lastActiveAt)} - {row.projectLabel} - {row.model ?? - translate('auto.components.stats.CodexUsagePane.bf6cf2d4dd', 'Unknown')} - {row.hasInferredPricing ? ' *' : ''} - {row.events} - {formatTokens(row.inputTokens)} - - {formatTokens(row.outputTokens)} - - {formatTokens(row.totalTokens)} -
-
-
+ ) } - -function CodexUsageBreakdownSection({ - formatTokens, - label, - rows, - showInferredPricing, - topLabel, - topValue -}: { - formatTokens: (value: number) => string - label: string - rows: CodexUsageBreakdownRow[] - showInferredPricing: boolean - topLabel: string - topValue: string | null | undefined -}): React.JSX.Element { - return ( -
-
-

{label}

-

- {topLabel}{' '} - {topValue ?? translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')} -

-
-
- {rows.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.totalTokens)} - -
-
- {row.sessions}{' '} - {translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '} - {row.events} {translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')} - {showInferredPricing && row.hasInferredPricing - ? ` ${translate( - 'auto.components.stats.CodexUsagePane.247c93ca92', - '• inferred pricing' - )}` - : ''} -
-
- ))} -
-
- ) -} diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index 0d848cdedf2..2ebd6913d44 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -305,7 +305,6 @@ export function CodexUsagePane(): React.JSX.Element { string - formatTokens: (value: number) => string modelBreakdown: OpenCodeUsageBreakdownRow[] projectBreakdown: OpenCodeUsageBreakdownRow[] recentSessions: OpenCodeUsageSessionRow[] summary: OpenCodeUsageSummary | null | undefined } -function formatSessionTime(timestamp: string): string { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return timestamp - } - return parsed.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - }) -} - export function OpenCodeUsageDetails({ daily, - formatCost, - formatTokens, modelBreakdown, projectBreakdown, recentSessions, @@ -44,143 +29,36 @@ export function OpenCodeUsageDetails({
- ({ + key: row.key, + label: row.label, + tokens: row.totalTokens, + sessions: row.sessions, + eventsOrTurns: row.events, + estimatedCostUsd: row.estimatedCostUsd + }))} + eventsOrTurns="events" /> - ({ + key: row.key, + label: row.label, + tokens: row.totalTokens, + sessions: row.sessions, + eventsOrTurns: row.events + }))} + eventsOrTurns="events" />
-
-
-

- {translate('auto.components.stats.OpenCodeUsagePane.4799177b1c', 'Recent sessions')} -

-

- {translate( - 'auto.components.stats.OpenCodeUsagePane.81817a641a', - 'Most recent local OpenCode sessions in this scope.' - )} -

-
-
- - - - - - - - - - - - - - {recentSessions.map((row) => ( - - - - - - - - - - ))} - -
- {translate('auto.components.stats.OpenCodeUsagePane.d97bdf6e27', 'Last active')} - - {translate('auto.components.stats.OpenCodeUsagePane.a4738de041', 'Project')} - - {translate('auto.components.stats.OpenCodeUsagePane.08c78441b7', 'Model')} - - {translate('auto.components.stats.OpenCodeUsagePane.d416f5cf92', 'Events')} - - {translate('auto.components.stats.OpenCodeUsagePane.0f2f266c9d', 'Input')} - - {translate('auto.components.stats.OpenCodeUsagePane.dfc4513657', 'Output')} - - {translate('auto.components.stats.OpenCodeUsagePane.349f7c3f5c', 'Total')} -
- {formatSessionTime(row.lastActiveAt)} - {row.projectLabel} - {row.model ?? - translate('auto.components.stats.OpenCodeUsagePane.362231082f', 'Unknown')} - {row.events} - {formatTokens(row.inputTokens)} - - {formatTokens(row.outputTokens)} - - {formatTokens(row.totalTokens)} -
-
-
+ ) } - -function OpenCodeUsageBreakdownSection({ - formatCost, - formatTokens, - label, - rows, - showCost, - topLabel, - topValue -}: { - formatCost: (value: number | null) => string - formatTokens: (value: number) => string - label: string - rows: OpenCodeUsageBreakdownRow[] - showCost: boolean - topLabel: string - topValue: string | null | undefined -}): React.JSX.Element { - return ( -
-
-

{label}

-

- {topLabel}{' '} - {topValue ?? translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')} -

-
-
- {rows.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.totalTokens)} - -
-
- {row.sessions}{' '} - {translate('auto.components.stats.OpenCodeUsagePane.bc0cb89901', 'sessions •')}{' '} - {row.events}{' '} - {translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')} - {showCost && row.estimatedCostUsd !== null - ? ` • ${formatCost(row.estimatedCostUsd)}` - : ''} -
-
- ))} -
-
- ) -} diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index 1bcee66ef6d..c09f2a5a179 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -325,8 +325,6 @@ export function OpenCodeUsagePane(): React.JSX.Element { +
+

{title}

+

+ {topLabel}{' '} + {topValue ?? translate('auto.components.stats.UsageBreakdownSection.7765a4c3e1', 'n/a')} +

+
+
+ {rows.slice(0, 5).map((row) => ( +
+
+ {row.label} + {formatTokens(row.tokens)} +
+
+ {row.sessions} {translate(sessionsKey, 'sessions •')} {row.eventsOrTurns}{' '} + {translate(eventsOrTurnsKey, eventsOrTurnsLabel)} + {row.hasInferredPricing + ? ` ${translate('auto.components.stats.UsageBreakdownSection.247c93ca92', '• inferred pricing')}` + : ''} + {row.estimatedCostUsd !== null && row.estimatedCostUsd !== undefined + ? ` • ${formatCost(row.estimatedCostUsd)}` + : ''} +
+
+ ))} +
+ + ) +} diff --git a/src/renderer/src/components/stats/UsageSessionsTable.tsx b/src/renderer/src/components/stats/UsageSessionsTable.tsx new file mode 100644 index 00000000000..c1401f002ca --- /dev/null +++ b/src/renderer/src/components/stats/UsageSessionsTable.tsx @@ -0,0 +1,105 @@ +import { translate } from '@/i18n/i18n' +import { formatTokens } from './usage-formatters' + +export type UsageSessionRow = { + sessionId: string + lastActiveAt: string + projectLabel: string + model: string | null + events?: number + turns?: number + inputTokens: number + outputTokens: number + cacheTokens?: number + totalTokens?: number + hasInferredPricing?: boolean +} + +function formatSessionTime(timestamp: string): string { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return timestamp + } + return parsed.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) +} + +type UsageSessionsTableProps = { + sessions: UsageSessionRow[] + eventsColumn?: 'events' | 'turns' + tokensColumn?: 'cache' | 'total' +} + +export function UsageSessionsTable({ + sessions, + eventsColumn = 'events', + tokensColumn = 'total' +}: UsageSessionsTableProps): React.JSX.Element { + const eventsLabel = + eventsColumn === 'turns' + ? translate('auto.components.stats.UsageSessionsTable.1afc25eb06', 'Turns') + : translate('auto.components.stats.UsageSessionsTable.0f03975d59', 'Events') + const tokensLabel = + tokensColumn === 'cache' + ? translate('auto.components.stats.UsageSessionsTable.21ea00bfa8', 'Cache') + : translate('auto.components.stats.UsageSessionsTable.e0b988599d', 'Total') + + return ( +
+ + + + + + + + + + + + + + {sessions.map((row) => ( + + + + + + + + + + ))} + +
+ {translate('auto.components.stats.UsageSessionsTable.01476891c7', 'Last active')} + + {translate('auto.components.stats.UsageSessionsTable.c17bed0416', 'Project')} + + {translate('auto.components.stats.UsageSessionsTable.f6a2c8d019', 'Model')} + {eventsLabel} + {translate('auto.components.stats.UsageSessionsTable.faf3444859', 'Input')} + + {translate('auto.components.stats.UsageSessionsTable.a8b7487ff7', 'Output')} + {tokensLabel}
+ {formatSessionTime(row.lastActiveAt)} + {row.projectLabel} + {row.model ?? + translate('auto.components.stats.UsageSessionsTable.cfe2282ffa', 'Unknown')} + {row.hasInferredPricing ? ' *' : ''} + + {eventsColumn === 'turns' ? row.turns : row.events} + {formatTokens(row.inputTokens)}{formatTokens(row.outputTokens)} + {formatTokens( + tokensColumn === 'cache' + ? (row.cacheTokens ?? 0) + : (row.totalTokens ?? row.inputTokens + row.outputTokens) + )} +
+
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4d26adf5305..83e1a8fe568 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1029,7 +1029,15 @@ "f660aa1454": "Connecting", "7711ad5122": "Local setup command", "e5db1b0419": "Combined setup command", - "addProjectBeforeWorkspace": "Add a project before creating a workspace." + "addProjectBeforeWorkspace": "Add a project before creating a workspace.", + "sshNotConnected": "SSH not connected", + "connectingSsh": "Connecting SSH...", + "sshAuthenticationFailed": "SSH authentication failed", + "preparingSshConnection": "Preparing SSH connection...", + "connected": "Connected", + "reconnectingSsh": "Reconnecting SSH...", + "sshReconnectionFailed": "SSH reconnection failed", + "notConnected": "Not connected" }, "NewWorkspaceComposerModal": { "fa90f739a5": "Choose the project, workspace name, and agent before creating the workspace." @@ -2825,7 +2833,11 @@ "4f8368c272": "Orca worktrees only", "cfe2282ffa": "Unknown", "7765a4c3e1": "n/a", - "2d41fd45c6": " • Last scan error: {{value0}}" + "2d41fd45c6": " • Last scan error: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "CodexUsageDailyChart": { "1e6f62d7e3": "Reasoning", @@ -2874,7 +2886,11 @@ "bf6cf2d4dd": "Unknown", "ae255c3dba": "n/a", "247c93ca92": "• inferred pricing", - "8a6655f7a2": " • Last scan error: {{value0}}" + "8a6655f7a2": " • Last scan error: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "OpenCodeUsagePane": { "349f7c3f5c": "Total", @@ -2913,7 +2929,11 @@ "e04c58327c": "Orca worktrees only", "362231082f": "Unknown", "8095a63426": "n/a", - "6cc7782458": " • Last scan error: {{value0}}" + "6cc7782458": " • Last scan error: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "ShareUsageButton": { "7d6b25323d": "Share on X", @@ -3032,6 +3052,22 @@ "d9a4b3e2f1c5": "events" } } + }, + "UsageBreakdownSection": { + "7765a4c3e1": "n/a", + "247c93ca92": "• inferred pricing" + }, + "UsageSessionsTable": { + "1afc25eb06": "Turns", + "0f03975d59": "Events", + "21ea00bfa8": "Cache", + "e0b988599d": "Total", + "01476891c7": "Last active", + "c17bed0416": "Project", + "f6a2c8d019": "Model", + "faf3444859": "Input", + "a8b7487ff7": "Output", + "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3521,7 +3557,11 @@ "376bed88e5": "The connection to the remote host encountered an error.", "4afcca1d24": "Reconnect", "11552bf786": "SSH Disconnected", - "cb5938ae79": "Reconnecting..." + "cb5938ae79": "Reconnecting...", + "disconnected": "This remote repository is not connected.", + "reconnecting": "Reconnecting to the remote host...", + "reconnectionFailed": "Reconnection to the remote host failed.", + "authFailed": "Authentication to the remote host failed." }, "SshTargetRow": { "4677394048": "Connecting…", @@ -7579,7 +7619,9 @@ "f273f2271c": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", "aa95b81a3a": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", "495b2f8c4b": "Started the agent, but could not mark the selected comments resolved.", - "3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host." + "3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host.", + "b4f3ec62a1": "More {{value0}} actions", + "a9d7c128e4": "unlink {{value0}}" }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancel", @@ -8963,7 +9005,11 @@ "3c4adfd821": "fix login race condition", "56a0271428": "Isolated workspaces", "ef737dcee1": "GitHub & Linear tasks", - "ac51c061e2": "codex" + "ac51c061e2": "codex", + "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", + "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", + "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", + "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." }, "FeatureWallBody": { "25ec5356d6": "Setup" @@ -9036,7 +9082,9 @@ "6e3f5223c5": "Explorer", "ab2901bce6": "Checks", "d7f80060ca": "Source Control", - "8e715588e4": "Search" + "8e715588e4": "Search", + "a6c8b9e32f": "Checks passed", + "f4d5e1a7b2": "3 checks" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "Create PR", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f365b395192..35ca5a126c9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -466,30 +466,30 @@ }, "folderWorkspacePathStatus": { "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder" + "missing": "Carpeta no encontrada", + "notDirectory": "La ruta no es una carpeta", + "ambiguousConnection": "No se puede determinar la conexión", + "unavailable": "No se puede comprobar la carpeta" }, "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + "missing": "Orca no puede encontrar {{path}}. Elimina y vuelve a importar este espacio de trabajo de carpeta.", + "notDirectory": "{{path}} existe, pero no es una carpeta.", + "ambiguousConnection": "Orca no puede determinar qué conexión SSH pertenece a este ámbito de carpeta.", + "unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo." }, "createError": { "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder", - "generic": "Failed to create folder workspace" + "missing": "Carpeta no encontrada", + "notDirectory": "La ruta no es una carpeta", + "ambiguousConnection": "No se puede determinar la conexión", + "unavailable": "No se puede comprobar la carpeta", + "generic": "No se pudo crear el espacio de trabajo de carpeta" }, "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + "missing": "Orca no puede encontrar {{path}}. Elimina y vuelve a importar la carpeta.", + "notDirectory": "{{path}} existe, pero no es una carpeta.", + "ambiguousConnection": "Orca no puede determinar qué conexión SSH pertenece a este ámbito de carpeta.", + "unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo." } } } @@ -1029,7 +1029,15 @@ "f660aa1454": "Conectando", "7711ad5122": "Comando de configuración local", "e5db1b0419": "Comando de configuración combinado", - "addProjectBeforeWorkspace": "Agregue un proyecto antes de crear un espacio de trabajo." + "addProjectBeforeWorkspace": "Agregue un proyecto antes de crear un espacio de trabajo.", + "sshNotConnected": "SSH not connected", + "connectingSsh": "Connecting SSH...", + "sshAuthenticationFailed": "SSH authentication failed", + "preparingSshConnection": "Preparing SSH connection...", + "connected": "Connected", + "reconnectingSsh": "Reconnecting SSH...", + "sshReconnectionFailed": "SSH reconnection failed", + "notConnected": "Not connected" }, "NewWorkspaceComposerModal": { "fa90f739a5": "Elija el proyecto, el nombre del espacio de trabajo y el agente antes de crear el espacio de trabajo." @@ -2825,7 +2833,11 @@ "4f8368c272": "Solo árboles de trabajo Orca", "cfe2282ffa": "Desconocido", "7765a4c3e1": "n / A", - "2d41fd45c6": "• Error del último análisis: {{value0}}" + "2d41fd45c6": "• Error del último análisis: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "CodexUsageDailyChart": { "1e6f62d7e3": "Razonamiento", @@ -2874,7 +2886,11 @@ "bf6cf2d4dd": "Desconocido", "ae255c3dba": "n / A", "247c93ca92": "• precios inferidos", - "8a6655f7a2": "• Error del último análisis: {{value0}}" + "8a6655f7a2": "• Error del último análisis: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "OpenCodeUsagePane": { "349f7c3f5c": "Total", @@ -2913,7 +2929,11 @@ "e04c58327c": "Solo árboles de trabajo Orca", "362231082f": "Desconocido", "8095a63426": "n / A", - "6cc7782458": "• Error del último análisis: {{value0}}" + "6cc7782458": "• Error del último análisis: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "ShareUsageButton": { "7d6b25323d": "Compartir en X", @@ -3032,6 +3052,22 @@ "d9a4b3e2f1c5": "eventos" } } + }, + "UsageBreakdownSection": { + "7765a4c3e1": "n/a", + "247c93ca92": "• inferred pricing" + }, + "UsageSessionsTable": { + "1afc25eb06": "Turns", + "0f03975d59": "Events", + "21ea00bfa8": "Cache", + "e0b988599d": "Total", + "01476891c7": "Last active", + "c17bed0416": "Project", + "f6a2c8d019": "Model", + "faf3444859": "Input", + "a8b7487ff7": "Output", + "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3521,7 +3557,11 @@ "376bed88e5": "La conexión al host remoto encontró un error.", "4afcca1d24": "Reconectar", "11552bf786": "SSH desconectado", - "cb5938ae79": "Reconectando..." + "cb5938ae79": "Reconectando...", + "disconnected": "This remote repository is not connected.", + "reconnecting": "Reconnecting to the remote host...", + "reconnectionFailed": "Reconnection to the remote host failed.", + "authFailed": "Authentication to the remote host failed." }, "SshTargetRow": { "4677394048": "Conectando…", @@ -7579,7 +7619,9 @@ "f273f2271c": "Agente iniciado. Marcados {{value0}} como resueltos, omitidos {{value1}}, con error {{value2}}.", "aa95b81a3a": "Agente iniciado. Marcados {{value0}} como resueltos, omitidos {{value1}}, con error {{value2}}.", "495b2f8c4b": "Agente iniciado, pero no se pudieron marcar los comentarios seleccionados como resueltos.", - "3c3ad3a1d2": "Agente iniciado. Ningún comentario seleccionado se puede marcar como resuelto en el host." + "3c3ad3a1d2": "Agente iniciado. Ningún comentario seleccionado se puede marcar como resuelto en el host.", + "b4f3ec62a1": "More {{value0}} actions", + "a9d7c128e4": "unlink {{value0}}" }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancelar", @@ -8963,7 +9005,11 @@ "3c4adfd821": "arreglar la condición de carrera de inicio de sesión", "56a0271428": "Espacios de trabajo aislados", "ef737dcee1": "GitHub y tareas Lineares", - "ac51c061e2": "codex" + "ac51c061e2": "codex", + "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", + "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", + "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", + "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." }, "FeatureWallBody": { "25ec5356d6": "Configuración" @@ -9036,7 +9082,9 @@ "6e3f5223c5": "Explorador", "ab2901bce6": "cheques", "d7f80060ca": "Control de fuente", - "8e715588e4": "Buscar" + "8e715588e4": "Buscar", + "a6c8b9e32f": "Checks passed", + "f4d5e1a7b2": "3 checks" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "Crear relaciones públicas", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index af80ad64554..c62fffc3d05 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -466,30 +466,30 @@ }, "folderWorkspacePathStatus": { "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder" + "missing": "フォルダーが見つかりません", + "notDirectory": "パスはフォルダーではありません", + "ambiguousConnection": "接続を特定できません", + "unavailable": "フォルダーを確認できません" }, "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + "missing": "Orca は {{path}} を見つけられません。このフォルダーワークスペースを削除して再インポートしてください。", + "notDirectory": "{{path}} は存在しますが、フォルダーではありません。", + "ambiguousConnection": "Orca はこのフォルダースコープをどの SSH 接続が所有しているか判別できません。", + "unavailable": "Orca は現在このフォルダーを確認できません。ランタイムまたは SSH 接続を確認して再試行してください。" }, "createError": { "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder", - "generic": "Failed to create folder workspace" + "missing": "フォルダーが見つかりません", + "notDirectory": "パスはフォルダーではありません", + "ambiguousConnection": "接続を特定できません", + "unavailable": "フォルダーを確認できません", + "generic": "フォルダーワークスペースの作成に失敗しました" }, "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + "missing": "Orca は {{path}} を見つけられません。フォルダーを削除して再インポートしてください。", + "notDirectory": "{{path}} は存在しますが、フォルダーではありません。", + "ambiguousConnection": "Orca はこのフォルダースコープをどの SSH 接続が所有しているか判別できません。", + "unavailable": "Orca は現在このフォルダーを確認できません。ランタイムまたは SSH 接続を確認して再試行してください。" } } } @@ -1029,7 +1029,15 @@ "f660aa1454": "接続中", "7711ad5122": "ローカルセットアップコマンド", "e5db1b0419": "組み合わせセットアップコマンド", - "addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。" + "addProjectBeforeWorkspace": "ワークスペースを作成する前にプロジェクトを追加します。", + "sshNotConnected": "SSH not connected", + "connectingSsh": "Connecting SSH...", + "sshAuthenticationFailed": "SSH authentication failed", + "preparingSshConnection": "Preparing SSH connection...", + "connected": "Connected", + "reconnectingSsh": "Reconnecting SSH...", + "sshReconnectionFailed": "SSH reconnection failed", + "notConnected": "Not connected" }, "NewWorkspaceComposerModal": { "fa90f739a5": "ワークスペースを作成する前に、プロジェクト、ワークスペース名、および agent を選択します。" @@ -2825,7 +2833,11 @@ "4f8368c272": "Orca ワークツリーのみ", "cfe2282ffa": "未知", "7765a4c3e1": "該当なし", - "2d41fd45c6": "• 最終スキャン エラー: {{value0}}" + "2d41fd45c6": "• 最終スキャン エラー: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "CodexUsageDailyChart": { "1e6f62d7e3": "推論", @@ -2874,7 +2886,11 @@ "bf6cf2d4dd": "未知", "ae255c3dba": "該当なし", "247c93ca92": "• 推定価格", - "8a6655f7a2": "• 最終スキャン エラー: {{value0}}" + "8a6655f7a2": "• 最終スキャン エラー: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "OpenCodeUsagePane": { "349f7c3f5c": "合計", @@ -2913,7 +2929,11 @@ "e04c58327c": "Orca ワークツリーのみ", "362231082f": "未知", "8095a63426": "該当なし", - "6cc7782458": "• 最終スキャン エラー: {{value0}}" + "6cc7782458": "• 最終スキャン エラー: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "ShareUsageButton": { "7d6b25323d": "Xで共有する", @@ -3032,6 +3052,22 @@ "d9a4b3e2f1c5": "イベント" } } + }, + "UsageBreakdownSection": { + "7765a4c3e1": "n/a", + "247c93ca92": "• inferred pricing" + }, + "UsageSessionsTable": { + "1afc25eb06": "Turns", + "0f03975d59": "Events", + "21ea00bfa8": "Cache", + "e0b988599d": "Total", + "01476891c7": "Last active", + "c17bed0416": "Project", + "f6a2c8d019": "Model", + "faf3444859": "Input", + "a8b7487ff7": "Output", + "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3502,7 +3538,11 @@ "376bed88e5": "リモート ホストへの接続でエラーが発生しました。", "4afcca1d24": "再接続", "11552bf786": "SSHが切断されました", - "cb5938ae79": "再接続中..." + "cb5938ae79": "再接続中...", + "disconnected": "This remote repository is not connected.", + "reconnecting": "Reconnecting to the remote host...", + "reconnectionFailed": "Reconnection to the remote host failed.", + "authFailed": "Authentication to the remote host failed." }, "SshTargetRow": { "4677394048": "接続中…", @@ -7579,7 +7619,9 @@ "f273f2271c": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。", "aa95b81a3a": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。", "495b2f8c4b": "agent を開始しましたが、選択したコメントを解決済みにできませんでした。", - "3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。" + "3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。", + "b4f3ec62a1": "More {{value0}} actions", + "a9d7c128e4": "unlink {{value0}}" }, "CreatePullRequestDialog": { "2bc1b4345e": "キャンセル", @@ -8963,7 +9005,11 @@ "3c4adfd821": "ログイン競合状態を修正", "56a0271428": "隔離されたワークスペース", "ef737dcee1": "GitHub とLinearタスク", - "ac51c061e2": "codex" + "ac51c061e2": "codex", + "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", + "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", + "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", + "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." }, "FeatureWallBody": { "25ec5356d6": "設定" @@ -9036,7 +9082,9 @@ "6e3f5223c5": "エクスプローラ", "ab2901bce6": "チェック", "d7f80060ca": "ソース管理", - "8e715588e4": "検索" + "8e715588e4": "検索", + "a6c8b9e32f": "Checks passed", + "f4d5e1a7b2": "3 checks" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "PRを作成する", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 378cbbd42d8..d084636b493 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -466,30 +466,30 @@ }, "folderWorkspacePathStatus": { "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder" + "missing": "폴더를 찾을 수 없습니다", + "notDirectory": "경로가 폴더가 아닙니다", + "ambiguousConnection": "연결을 확인할 수 없습니다", + "unavailable": "폴더를 확인할 수 없습니다" }, "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + "missing": "Orca가 {{path}}을(를) 찾을 수 없습니다. 이 폴더 워크스페이스를 제거하고 다시 가져오세요.", + "notDirectory": "{{path}}이(가) 존재하지만 폴더가 아닙니다.", + "ambiguousConnection": "Orca가 이 폴더 범위를 소유한 SSH 연결을 확인할 수 없습니다.", + "unavailable": "Orca가 지금 이 폴더를 확인할 수 없습니다. 런타임 또는 SSH 연결을 확인한 후 다시 시도하세요." }, "createError": { "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder", - "generic": "Failed to create folder workspace" + "missing": "폴더를 찾을 수 없습니다", + "notDirectory": "경로가 폴더가 아닙니다", + "ambiguousConnection": "연결을 확인할 수 없습니다", + "unavailable": "폴더를 확인할 수 없습니다", + "generic": "폴더 워크스페이스를 만들지 못했습니다" }, "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + "missing": "Orca가 {{path}}을(를) 찾을 수 없습니다. 폴더를 제거하고 다시 가져오세요.", + "notDirectory": "{{path}}이(가) 존재하지만 폴더가 아닙니다.", + "ambiguousConnection": "Orca가 이 폴더 범위를 소유한 SSH 연결을 확인할 수 없습니다.", + "unavailable": "Orca가 지금 이 폴더를 확인할 수 없습니다. 런타임 또는 SSH 연결을 확인한 후 다시 시도하세요." } } } @@ -1029,7 +1029,15 @@ "f660aa1454": "연결 중", "7711ad5122": "로컬 설정 명령", "e5db1b0419": "결합된 설정 명령", - "addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요." + "addProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 추가하세요.", + "sshNotConnected": "SSH not connected", + "connectingSsh": "Connecting SSH...", + "sshAuthenticationFailed": "SSH authentication failed", + "preparingSshConnection": "Preparing SSH connection...", + "connected": "Connected", + "reconnectingSsh": "Reconnecting SSH...", + "sshReconnectionFailed": "SSH reconnection failed", + "notConnected": "Not connected" }, "NewWorkspaceComposerModal": { "fa90f739a5": "워크스페이스를 생성하기 전에 프로젝트, 워크스페이스 이름, agent를 선택하세요." @@ -2825,7 +2833,11 @@ "4f8368c272": "Orca 작업 트리만 해당", "cfe2282ffa": "알려지지 않은", "7765a4c3e1": "해당 없음", - "2d41fd45c6": "• 마지막 스캔 오류: {{value0}}" + "2d41fd45c6": "• 마지막 스캔 오류: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "CodexUsageDailyChart": { "1e6f62d7e3": "추리", @@ -2874,7 +2886,11 @@ "bf6cf2d4dd": "알려지지 않은", "ae255c3dba": "해당 없음", "247c93ca92": "• 추론된 가격", - "8a6655f7a2": "• 마지막 스캔 오류: {{value0}}" + "8a6655f7a2": "• 마지막 스캔 오류: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "OpenCodeUsagePane": { "349f7c3f5c": "총", @@ -2913,7 +2929,11 @@ "e04c58327c": "Orca 작업 트리만 해당", "362231082f": "알려지지 않은", "8095a63426": "해당 없음", - "6cc7782458": "• 마지막 스캔 오류: {{value0}}" + "6cc7782458": "• 마지막 스캔 오류: {{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "ShareUsageButton": { "7d6b25323d": "X에 공유", @@ -3032,6 +3052,22 @@ "d9a4b3e2f1c5": "이벤트" } } + }, + "UsageBreakdownSection": { + "7765a4c3e1": "n/a", + "247c93ca92": "• inferred pricing" + }, + "UsageSessionsTable": { + "1afc25eb06": "Turns", + "0f03975d59": "Events", + "21ea00bfa8": "Cache", + "e0b988599d": "Total", + "01476891c7": "Last active", + "c17bed0416": "Project", + "f6a2c8d019": "Model", + "faf3444859": "Input", + "a8b7487ff7": "Output", + "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3502,7 +3538,11 @@ "376bed88e5": "원격 호스트 연결에 오류가 발생했습니다.", "4afcca1d24": "다시 연결", "11552bf786": "SSH 연결 끊김", - "cb5938ae79": "다시 연결하는 중..." + "cb5938ae79": "다시 연결하는 중...", + "disconnected": "This remote repository is not connected.", + "reconnecting": "Reconnecting to the remote host...", + "reconnectionFailed": "Reconnection to the remote host failed.", + "authFailed": "Authentication to the remote host failed." }, "SshTargetRow": { "4677394048": "연결 중…", @@ -7579,7 +7619,9 @@ "f273f2271c": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.", "aa95b81a3a": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.", "495b2f8c4b": "agent를 시작했지만 선택한 댓글을 해결됨으로 표시할 수 없습니다.", - "3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다." + "3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다.", + "b4f3ec62a1": "More {{value0}} actions", + "a9d7c128e4": "unlink {{value0}}" }, "CreatePullRequestDialog": { "2bc1b4345e": "취소", @@ -8963,7 +9005,11 @@ "3c4adfd821": "로그인 경쟁 조건 수정", "56a0271428": "격리된 워크스페이스", "ef737dcee1": "GitHub 및 Linear 작업", - "ac51c061e2": "codex" + "ac51c061e2": "codex", + "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", + "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", + "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", + "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." }, "FeatureWallBody": { "25ec5356d6": "설정" @@ -9036,7 +9082,9 @@ "6e3f5223c5": "탐침", "ab2901bce6": "검사", "d7f80060ca": "소스 제어", - "8e715588e4": "검색" + "8e715588e4": "검색", + "a6c8b9e32f": "Checks passed", + "f4d5e1a7b2": "3 checks" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "PR 작성", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 93c92b05708..cbe51f34069 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -466,30 +466,30 @@ }, "folderWorkspacePathStatus": { "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder" + "missing": "未找到文件夹", + "notDirectory": "路径不是文件夹", + "ambiguousConnection": "无法确定连接", + "unavailable": "无法检查文件夹" }, "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + "missing": "Orca 找不到 {{path}}。请移除并重新导入此文件夹工作区。", + "notDirectory": "{{path}} 存在,但它不是文件夹。", + "ambiguousConnection": "Orca 无法判断哪个 SSH 连接拥有此文件夹范围。", + "unavailable": "Orca 现在无法验证此文件夹。请检查运行时或 SSH 连接,然后重试。" }, "createError": { "title": { - "missing": "Folder not found", - "notDirectory": "Path is not a folder", - "ambiguousConnection": "Cannot determine connection", - "unavailable": "Cannot check folder", - "generic": "Failed to create folder workspace" + "missing": "未找到文件夹", + "notDirectory": "路径不是文件夹", + "ambiguousConnection": "无法确定连接", + "unavailable": "无法检查文件夹", + "generic": "创建文件夹工作区失败" }, "description": { - "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", - "notDirectory": "{{path}} exists, but it is not a folder.", - "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", - "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + "missing": "Orca 找不到 {{path}}。请移除并重新导入该文件夹。", + "notDirectory": "{{path}} 存在,但它不是文件夹。", + "ambiguousConnection": "Orca 无法判断哪个 SSH 连接拥有此文件夹范围。", + "unavailable": "Orca 现在无法验证此文件夹。请检查运行时或 SSH 连接,然后重试。" } } } @@ -1029,7 +1029,15 @@ "f660aa1454": "连接中", "7711ad5122": "本地设置命令", "e5db1b0419": "组合设置命令", - "addProjectBeforeWorkspace": "在创建工作区之前添加项目。" + "addProjectBeforeWorkspace": "在创建工作区之前添加项目。", + "sshNotConnected": "SSH not connected", + "connectingSsh": "Connecting SSH...", + "sshAuthenticationFailed": "SSH authentication failed", + "preparingSshConnection": "Preparing SSH connection...", + "connected": "Connected", + "reconnectingSsh": "Reconnecting SSH...", + "sshReconnectionFailed": "SSH reconnection failed", + "notConnected": "Not connected" }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。" @@ -2825,7 +2833,11 @@ "4f8368c272": "仅 Orca 工作树", "cfe2282ffa": "未知", "7765a4c3e1": "不适用", - "2d41fd45c6": "• 上次扫描错误:{{value0}}" + "2d41fd45c6": "• 上次扫描错误:{{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "CodexUsageDailyChart": { "1e6f62d7e3": "推理", @@ -2874,7 +2886,11 @@ "bf6cf2d4dd": "未知", "ae255c3dba": "不适用", "247c93ca92": "• 推断定价", - "8a6655f7a2": "• 上次扫描错误:{{value0}}" + "8a6655f7a2": "• 上次扫描错误:{{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "OpenCodeUsagePane": { "349f7c3f5c": "全部的", @@ -2913,7 +2929,11 @@ "e04c58327c": "仅 Orca 工作树", "362231082f": "未知", "8095a63426": "不适用", - "6cc7782458": "• 上次扫描错误:{{value0}}" + "6cc7782458": "• 上次扫描错误:{{value0}}", + "rangeLast7Days": "Last 7 days", + "rangeLast30Days": "Last 30 days", + "rangeLast90Days": "Last 90 days", + "rangeAllTime": "All time" }, "ShareUsageButton": { "7d6b25323d": "分享到 X", @@ -3032,6 +3052,22 @@ "d9a4b3e2f1c5": "事件" } } + }, + "UsageBreakdownSection": { + "7765a4c3e1": "n/a", + "247c93ca92": "• inferred pricing" + }, + "UsageSessionsTable": { + "1afc25eb06": "Turns", + "0f03975d59": "Events", + "21ea00bfa8": "Cache", + "e0b988599d": "Total", + "01476891c7": "Last active", + "c17bed0416": "Project", + "f6a2c8d019": "Model", + "faf3444859": "Input", + "a8b7487ff7": "Output", + "cfe2282ffa": "Unknown" } }, "sparse": { @@ -3502,7 +3538,11 @@ "376bed88e5": "与远程主机的连接遇到错误。", "4afcca1d24": "重新连接", "11552bf786": "SSH 已断开", - "cb5938ae79": "正在重新连接..." + "cb5938ae79": "正在重新连接...", + "disconnected": "This remote repository is not connected.", + "reconnecting": "Reconnecting to the remote host...", + "reconnectionFailed": "Reconnection to the remote host failed.", + "authFailed": "Authentication to the remote host failed." }, "SshTargetRow": { "4677394048": "正在连接…", @@ -7579,7 +7619,9 @@ "f273f2271c": "已启动 agent。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", "aa95b81a3a": "已启动 agent。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", "495b2f8c4b": "已启动 agent,但无法将选中的评论标记为已解决。", - "3c3ad3a1d2": "已启动 agent。选中的评论中没有可在托管平台上标记为已解决的评论。" + "3c3ad3a1d2": "已启动 agent。选中的评论中没有可在托管平台上标记为已解决的评论。", + "b4f3ec62a1": "More {{value0}} actions", + "a9d7c128e4": "unlink {{value0}}" }, "CreatePullRequestDialog": { "2bc1b4345e": "取消", @@ -8963,7 +9005,11 @@ "3c4adfd821": "修复登录竞争条件", "56a0271428": "独立的工作区", "ef737dcee1": "GitHub 和 Linear 任务", - "ac51c061e2": "codex" + "ac51c061e2": "codex", + "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", + "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", + "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", + "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." }, "FeatureWallBody": { "25ec5356d6": "设置" @@ -9036,7 +9082,9 @@ "6e3f5223c5": "探险家", "ab2901bce6": "检查项", "d7f80060ca": "源代码控制", - "8e715588e4": "搜索" + "8e715588e4": "搜索", + "a6c8b9e32f": "Checks passed", + "f4d5e1a7b2": "3 checks" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "创建PR",