mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/win-codex-merge-0903
This commit is contained in:
@@ -13,3 +13,10 @@ description = "Cloud SQL rollout lease holder keys in the action's unit tests"
|
||||
regexTarget = "secret"
|
||||
paths = ['''\.github/actions/cloud-sql-rollout-lease/[a-z-]+\.test\.mjs$''']
|
||||
regexes = ['''^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/[0-9]+$''']
|
||||
|
||||
# RFC 6455 §1.3 example handshake nonce ("the sample nonce" in base64), sent by the raw-socket
|
||||
# upgrade tests; the generic key rule reads any base64 header value as a secret.
|
||||
[[allowlists]]
|
||||
description = "RFC 6455 example Sec-WebSocket-Key in upgrade tests"
|
||||
regexTarget = "secret"
|
||||
regexes = ['''^dGhlIHNhbXBsZSBub25jZQ==$''']
|
||||
|
||||
@@ -118,6 +118,39 @@ describe('PostgreSQL schema startup', () => {
|
||||
expect(query).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['42710', 'CREATE TABLE IF NOT EXISTS test'],
|
||||
['42P07', 'CREATE TABLE IF NOT EXISTS test'],
|
||||
['42P07', 'CREATE INDEX IF NOT EXISTS test_index ON test(id)'],
|
||||
['42P07', 'CREATE UNIQUE INDEX IF NOT EXISTS test_index ON test(id)']
|
||||
])('retries the committed-winner %s collision for %s', async (code, statement) => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const collision = Object.assign(new Error('already exists'), { code })
|
||||
const query = vi
|
||||
.fn<(statement: string) => Promise<unknown>>()
|
||||
.mockRejectedValueOnce(collision)
|
||||
.mockResolvedValue(undefined)
|
||||
|
||||
await applyPostgresSchema([statement], query, { wait: async () => undefined })
|
||||
|
||||
expect(query).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['42710', 'CREATE INDEX IF NOT EXISTS test_index ON test(id)'],
|
||||
['42710', 'CREATE TABLE test'],
|
||||
['42P07', 'CREATE TABLE test'],
|
||||
['42P07', 'CREATE INDEX test_index ON test(id)']
|
||||
])('does not retry %s for %s', async (code, statement) => {
|
||||
const error = Object.assign(new Error('already exists'), { code })
|
||||
const query = vi.fn<(statement: string) => Promise<unknown>>().mockRejectedValue(error)
|
||||
const pause = vi.fn(async () => undefined)
|
||||
|
||||
await expect(applyPostgresSchema([statement], query, { wait: pause })).rejects.toBe(error)
|
||||
|
||||
expect(pause).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['pg_type_typname_nsp_index', 'CREATE TABLE test'],
|
||||
['pg_class_relname_nsp_index', 'CREATE INDEX test_index ON test(id)']
|
||||
|
||||
@@ -34,22 +34,28 @@ describePostgres('PostgreSQL schema concurrency', () => {
|
||||
})
|
||||
|
||||
it('opens five directors when one new table is absent', async () => {
|
||||
const initial = await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' })
|
||||
await initial.query(`DROP TABLE relay_cell_legacy_fence_adoptions`)
|
||||
await initial.close()
|
||||
// Which catalog step the race loser fails on depends on scheduling, so run several rounds and
|
||||
// keep the loser's SQLSTATE in the failure instead of a bare boolean.
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
const initial = await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' })
|
||||
await initial.query(`DROP TABLE relay_cell_legacy_fence_adoptions`)
|
||||
await initial.close()
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: 5 }, async (): Promise<RelayDatabase> =>
|
||||
await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' })
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: 5 }, async (): Promise<RelayDatabase> =>
|
||||
await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' })
|
||||
)
|
||||
)
|
||||
const databases = results.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : []
|
||||
)
|
||||
)
|
||||
const databases = results.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : []
|
||||
)
|
||||
try {
|
||||
expect(results.every((result) => result.status === 'fulfilled')).toBe(true)
|
||||
} finally {
|
||||
await Promise.all(databases.map(async (database) => await database.close()))
|
||||
const rejections = results.flatMap((result) =>
|
||||
result.status === 'rejected'
|
||||
? [{ round, code: (result.reason as { code?: unknown }).code, message: String(result.reason) }]
|
||||
: []
|
||||
)
|
||||
expect(rejections).toEqual([])
|
||||
}
|
||||
})
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
@@ -22,15 +22,37 @@ function wait(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
const CREATE_TABLE_IF_NOT_EXISTS = /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i
|
||||
const CREATE_INDEX_IF_NOT_EXISTS = /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i
|
||||
|
||||
// `IF NOT EXISTS` only checks the name before the catalog inserts, so the loser of a concurrent
|
||||
// CREATE can fail on the catalog unique index (23505) or, when the winner has already committed by
|
||||
// the time the loser reaches TypeCreate/heap_create_with_catalog, on the name check those routines
|
||||
// repeat (42710 duplicate type, 42P07 duplicate relation). Each is a no-op on the next attempt.
|
||||
function concurrentCreateCollision(
|
||||
value: { code?: unknown; constraint?: unknown },
|
||||
statement: string
|
||||
): boolean {
|
||||
if (CREATE_TABLE_IF_NOT_EXISTS.test(statement)) {
|
||||
return (
|
||||
(value.code === '23505' && value.constraint === 'pg_type_typname_nsp_index') ||
|
||||
value.code === '42710' ||
|
||||
value.code === '42P07'
|
||||
)
|
||||
}
|
||||
if (CREATE_INDEX_IF_NOT_EXISTS.test(statement)) {
|
||||
return (
|
||||
(value.code === '23505' && value.constraint === 'pg_class_relname_nsp_index') ||
|
||||
value.code === '42P07'
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function retryableSchemaError(error: unknown, statement: string): boolean {
|
||||
const value = error as { code?: unknown; constraint?: unknown }
|
||||
return (
|
||||
RETRYABLE_SCHEMA_CODES.has(String(value.code)) ||
|
||||
(value.code === '23505' &&
|
||||
((value.constraint === 'pg_type_typname_nsp_index' &&
|
||||
/^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i.test(statement)) ||
|
||||
(value.constraint === 'pg_class_relname_nsp_index' &&
|
||||
/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i.test(statement))))
|
||||
RETRYABLE_SCHEMA_CODES.has(String(value.code)) || concurrentCreateCollision(value, statement)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,16 @@ import { createRelayTokenVerifier, readBearer } from './relay-token-verifier.js'
|
||||
import { closeRelayWebSocket } from './relay-websocket-close.js'
|
||||
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
|
||||
|
||||
// A malformed percent-escape in the request target must be a client error, never a URIError
|
||||
// thrown out of the `upgrade` listener (which is uncaught and kills the process).
|
||||
function decodePathSegment(value: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket: NodeJS.WritableStream, status: number, message: string): void {
|
||||
socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||
if ('destroy' in socket && typeof socket.destroy === 'function') socket.destroy()
|
||||
@@ -278,8 +288,8 @@ export function createRelayServer(
|
||||
return
|
||||
}
|
||||
if (url.pathname.startsWith('/v1/connect/')) {
|
||||
const hostId = decodeURIComponent(url.pathname.slice('/v1/connect/'.length))
|
||||
if (!/^[A-Za-z0-9_-]{16}$/.test(hostId)) {
|
||||
const hostId = decodePathSegment(url.pathname.slice('/v1/connect/'.length))
|
||||
if (hostId === null || !/^[A-Za-z0-9_-]{16}$/.test(hostId)) {
|
||||
rejectUpgrade(socket, 429, 'Too Many Requests')
|
||||
return
|
||||
}
|
||||
@@ -373,7 +383,7 @@ export function createRelayServer(
|
||||
rejectUpgrade(socket, 404, 'Not Found')
|
||||
return
|
||||
}
|
||||
const connId = decodeURIComponent(url.pathname.slice('/v1/host/data/'.length))
|
||||
const connId = decodePathSegment(url.pathname.slice('/v1/host/data/'.length))
|
||||
if (!connId || connId.length > 128) {
|
||||
rejectUpgrade(socket, 429, 'Too Many Requests')
|
||||
return
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { connect, createServer as createNetServer } from 'node:net'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RelayConfig } from './config.js'
|
||||
import type { RelayDatabase } from './database.js'
|
||||
import { createRelayServer } from './relay-server.js'
|
||||
|
||||
async function unusedPort(): Promise<number> {
|
||||
const server = createNetServer()
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('missing test port')
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
return address.port
|
||||
}
|
||||
|
||||
function rawUpgrade(port: number, target: string): Promise<{ status: string; closed: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(port, '127.0.0.1')
|
||||
let data = ''
|
||||
socket.once('connect', () => {
|
||||
socket.write(
|
||||
`GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\n` +
|
||||
'Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\n' +
|
||||
// RFC 6455 §1.3 example nonce; allowlisted in cloud/.gitleaks.toml.
|
||||
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n'
|
||||
)
|
||||
})
|
||||
socket.on('data', (chunk) => {
|
||||
data += chunk.toString()
|
||||
})
|
||||
socket.once('close', () => resolve({ status: data.split('\r\n')[0] ?? '', closed: true }))
|
||||
socket.once('error', reject)
|
||||
setTimeout(() => {
|
||||
socket.destroy()
|
||||
resolve({ status: data.split('\r\n')[0] ?? '', closed: false })
|
||||
}, 1_500).unref()
|
||||
})
|
||||
}
|
||||
|
||||
describe('relay upgrade with a malformed request target', () => {
|
||||
const cleanup: Array<() => Promise<void> | void> = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const close of cleanup.splice(0).reverse()) await close()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('rejects an undecodable /v1/connect path without an uncaught exception', async () => {
|
||||
const port = await unusedPort()
|
||||
const relayUrl = `http://127.0.0.1:${port}`
|
||||
const database: RelayDatabase = {
|
||||
query: vi.fn(async () => []),
|
||||
queryLocked: vi.fn(async () => []),
|
||||
transaction: vi.fn(async (operation) => await operation(database)),
|
||||
close: vi.fn(async () => undefined)
|
||||
}
|
||||
const config = {
|
||||
port,
|
||||
publicUrl: relayUrl,
|
||||
cellUrl: relayUrl,
|
||||
authIssuer: 'https://auth.example.com',
|
||||
authAudience: 'orca-relay',
|
||||
jwksUrl: 'https://auth.example.com/jwks',
|
||||
assignmentSigningKey: new Uint8Array(32),
|
||||
role: 'cell',
|
||||
cellId: 'production-gce-c3',
|
||||
cells: [{ id: 'production-gce-c3', url: relayUrl, capacityRequests: 4_000 }],
|
||||
adminAudience: `${relayUrl}/admin`,
|
||||
deployServiceAccount: 'deploy@example.com',
|
||||
runtimeServiceAccount: 'runtime@example.com',
|
||||
connectionHardCap: 600,
|
||||
connectionUnobservedBound: 60,
|
||||
adminJwksUrl: 'https://auth.example.com/admin-jwks',
|
||||
databasePoolMax: 10,
|
||||
publicAssignmentsEnabled: true,
|
||||
publicAssignmentConcurrency: 2,
|
||||
publicAssignmentQueueMax: 128,
|
||||
publicAssignmentWaitMs: 4_000,
|
||||
publicResolveConcurrency: 1,
|
||||
publicResolveWaitMs: 5_000,
|
||||
publicAssignmentRetryAfterSeconds: 5,
|
||||
dataDir: './test-data'
|
||||
} satisfies RelayConfig
|
||||
const relay = createRelayServer(config, database, {
|
||||
connectionLedgerLimits: { hardCap: 5, controlReserve: 1 }
|
||||
})
|
||||
relay.server.listen(port, '127.0.0.1')
|
||||
await new Promise<void>((resolve) => relay.server.once('listening', resolve))
|
||||
cleanup.push(() => new Promise<void>((resolve) => relay.server.close(() => resolve())))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
// Vitest installs its own uncaughtException listener; capture ours first so the test reports
|
||||
// the exception as a verdict instead of dying with it.
|
||||
const uncaught: unknown[] = []
|
||||
const onUncaught = (error: unknown): void => {
|
||||
uncaught.push(error)
|
||||
}
|
||||
process.prependListener('uncaughtException', onUncaught)
|
||||
cleanup.push(() => {
|
||||
process.off('uncaughtException', onUncaught)
|
||||
})
|
||||
|
||||
const results = []
|
||||
for (const target of [
|
||||
'/v1/connect/%',
|
||||
'/v1/connect/%E0%A4%A',
|
||||
'/v1/connect/%C0%AF',
|
||||
'/v1/host/data/%'
|
||||
]) {
|
||||
results.push(await rawUpgrade(port, target))
|
||||
}
|
||||
// A malformed percent-escape must be a client error, never a process-level throw.
|
||||
expect(uncaught).toEqual([])
|
||||
for (const result of results) {
|
||||
expect(result.status).toMatch(/^HTTP\/1\.1 4\d\d/)
|
||||
}
|
||||
// The server must still serve a well-formed upgrade afterwards.
|
||||
const after = await rawUpgrade(port, '/v1/connect/abcdefghijklmnop')
|
||||
expect(after.status).toMatch(/^HTTP\/1\.1 101/)
|
||||
})
|
||||
})
|
||||
@@ -55,6 +55,13 @@
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Langue",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
|
||||
@@ -63,11 +63,11 @@ const HOST_COMPONENT_NAMES = new Set([
|
||||
])
|
||||
|
||||
const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6'
|
||||
const HEAD_HOOK_BINDING_SHA256 = 'ecd4c1dad066cf13698447b8ffb61f82e6cc3ebe7d484f71189626efed430272'
|
||||
const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1'
|
||||
const HEAD_CALLBACK_IDENTITY_SHA256 =
|
||||
'df073bc13d94a93e7fbd8b1fca2b57eaf43cbf7ca799a649e0ebb783e5b8eecc'
|
||||
const HEAD_CALLBACK_BODY_SHA256 = '690e3069e08ecf805af726b658e900c973565259160f25e3a643175e2ab1bc75'
|
||||
const HEAD_EFFECT_SHA256 = '346d384ea0bf2f8f926c5092c5bf57bc2a03494f49f9639e9d6b8a2c51c9f882'
|
||||
'2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb'
|
||||
const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9'
|
||||
const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13'
|
||||
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
|
||||
const HEAD_NESTED_FUNCTION_SHA256 =
|
||||
'6a13919ede2a8033436fb03e0ff7c426fbed97f470875a7b21b00aaada17fb73'
|
||||
@@ -79,13 +79,13 @@ const HEAD_TIMER_CREATION_SHA256 =
|
||||
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
|
||||
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
|
||||
const HEAD_RUNTIME_STRING_SHA256 =
|
||||
'1cb95fe0095c1c57e1b0629472e1cce5328eb7f5bfeca38095f41f4612a37887'
|
||||
'ba52a3ede721bd29acbe8593161e90b216b7f361ff896e085927d4d73fa83b2f'
|
||||
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
|
||||
const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016'
|
||||
const HEAD_STYLE_REFERENCE_SHA256 =
|
||||
'295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
|
||||
const HEAD_IDENTITY_FIELD_SHA256 =
|
||||
'6b37a0351795a387a358df76a5ab919a7098ddb76bf25a936c8902c062c8951c'
|
||||
'91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6'
|
||||
const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512'
|
||||
const HEAD_CAPABILITY_SHA256 = 'ca219f7909a091717110b823d5b94a20770ad3ae51894e0fa765e8628309392d'
|
||||
|
||||
@@ -517,7 +517,7 @@ describe('mobile session route extraction parity', () => {
|
||||
|
||||
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
|
||||
const strings = readRuntimeStrings()
|
||||
expect(strings).toHaveLength(545)
|
||||
expect(strings).toHaveLength(546)
|
||||
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
|
||||
const jsx = readJsxFacts(readDefinitions())
|
||||
expect(jsx.host).toHaveLength(124)
|
||||
|
||||
@@ -29,6 +29,14 @@ const tabReconciliationOwnerSource = readMobileSessionRouteSource(
|
||||
const autoCreateHookSource = readMobileSessionRouteSource(
|
||||
'./use-initial-session-terminal-autocreate.ts'
|
||||
)
|
||||
const foundationSource = readMobileSessionRouteSource('./use-mobile-session-foundation.ts')
|
||||
const terminalRuntimeSource = readMobileSessionRouteSource(
|
||||
'./use-mobile-session-terminal-runtime.ts'
|
||||
)
|
||||
const terminalSubscriptionSourceForIdentity = readMobileSessionRouteSource(
|
||||
'./use-mobile-session-terminal-subscription.ts'
|
||||
)
|
||||
const lifecycleSource = readMobileSessionRouteSource('./use-mobile-session-lifecycle.ts')
|
||||
|
||||
function sliceBetween(startPattern: string, endPattern: string, targetSource = source): string {
|
||||
const start = targetSource.indexOf(startPattern)
|
||||
@@ -106,6 +114,19 @@ describe('mobile session startup', () => {
|
||||
expect(reconciliationHookSource).toContain('appStateSubscription.remove()')
|
||||
})
|
||||
|
||||
it('binds terminal identity to the shared client before subscription effects run', () => {
|
||||
expect(foundationSource).toContain('const { client, clientId, state: connState }')
|
||||
expect(foundationSource).toContain(' clientId,')
|
||||
expect(terminalRuntimeSource).toContain('useRef<string | null>(clientId)')
|
||||
expect(terminalRuntimeSource).toContain('deviceTokenRef.current = clientId')
|
||||
expect(terminalRuntimeSource).toContain('inputGate.canSend && clientId !== null')
|
||||
expect(terminalSubscriptionSourceForIdentity).toContain('if (clientId === null)')
|
||||
expect(terminalSubscriptionSourceForIdentity).toContain(
|
||||
"client: { id: clientId, type: 'mobile' as const }"
|
||||
)
|
||||
expect(lifecycleSource).not.toContain('deviceTokenRef.current = host.deviceToken')
|
||||
})
|
||||
|
||||
it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => {
|
||||
expect(terminalSubscriptionSource).toContain(
|
||||
"if (data.type === 'end' || data.type === 'error')"
|
||||
|
||||
@@ -35,7 +35,7 @@ export function useMobileSessionFoundation() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
// Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const { client, clientId, state: connState } = useHostClient(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
@@ -96,6 +96,7 @@ export function useMobileSessionFoundation() {
|
||||
router,
|
||||
insets,
|
||||
client,
|
||||
clientId,
|
||||
connState,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt,
|
||||
|
||||
@@ -16,7 +16,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
|
||||
connState,
|
||||
setCustomKeys,
|
||||
setVisibleBuiltInIds,
|
||||
deviceTokenRef,
|
||||
setHostEndpoint,
|
||||
connStateRef,
|
||||
terminalRefs,
|
||||
@@ -26,7 +25,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
|
||||
unsubscribeTerminal,
|
||||
subscribeToTerminal
|
||||
} = scope
|
||||
// Why: read deviceToken from host record so code can pass client.id on subscribe/send for driver-state-machine identity.
|
||||
// Why: the shared client owns authenticated identity; this host read only supplies connection-hint metadata.
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
return
|
||||
@@ -38,7 +37,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
|
||||
}
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (host) {
|
||||
deviceTokenRef.current = host.deviceToken
|
||||
setHostEndpoint(host.endpoint)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
|
||||
worktreeId,
|
||||
connState,
|
||||
client,
|
||||
clientId,
|
||||
sessionTabs,
|
||||
setLiveInputCapture,
|
||||
liveInputTerminalHandles,
|
||||
@@ -42,7 +43,9 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
|
||||
const terminalGestureInputInFlightRef = useRef<Set<string>>(new Set())
|
||||
const terminalCwdRef = useRef<Map<string, string>>(new Map())
|
||||
const initialModesSeenRef = useRef<Set<string>>(new Set())
|
||||
const deviceTokenRef = useRef<string | null>(null)
|
||||
const deviceTokenRef = useRef<string | null>(clientId)
|
||||
// Keep the authenticated identity synchronous with the client exposed to downstream hooks.
|
||||
deviceTokenRef.current = clientId
|
||||
// Why: state (not a ref) so the connection verdict re-renders when the endpoint loads and the Tailscale hint can appear.
|
||||
const [hostEndpoint, setHostEndpoint] = useState<string | null>(null)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
@@ -123,11 +126,13 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
})
|
||||
const { canCompose, canSend } = resolveMobileTerminalInputGate({
|
||||
const inputGate = resolveMobileTerminalInputGate({
|
||||
connState,
|
||||
activeHandle,
|
||||
activeSessionTabType: activeSessionTab?.type
|
||||
})
|
||||
const canCompose = inputGate.canCompose
|
||||
const canSend = inputGate.canSend && clientId !== null
|
||||
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
|
||||
const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({
|
||||
activeHandleRef,
|
||||
|
||||
@@ -15,9 +15,9 @@ export function useMobileSessionTerminalSubscription(
|
||||
) {
|
||||
const {
|
||||
client,
|
||||
clientId,
|
||||
setTerminalModes,
|
||||
terminalCwdRef,
|
||||
deviceTokenRef,
|
||||
viewportRef,
|
||||
viewportMeasuredRef,
|
||||
terminalUnsubsRef,
|
||||
@@ -49,6 +49,10 @@ export function useMobileSessionTerminalSubscription(
|
||||
logSkippedGate('no-client')
|
||||
return
|
||||
}
|
||||
if (clientId === null) {
|
||||
logSkippedGate('no-client-identity')
|
||||
return
|
||||
}
|
||||
if (terminalUnsubsRef.current.has(handle)) {
|
||||
logSkippedGate('already-subscribed')
|
||||
return
|
||||
@@ -89,7 +93,7 @@ export function useMobileSessionTerminalSubscription(
|
||||
client,
|
||||
{
|
||||
terminal: handle,
|
||||
client: { id: deviceTokenRef.current!, type: 'mobile' as const },
|
||||
client: { id: clientId, type: 'mobile' as const },
|
||||
viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport(
|
||||
covered,
|
||||
viewportRef.current
|
||||
@@ -263,6 +267,7 @@ export function useMobileSessionTerminalSubscription(
|
||||
},
|
||||
[
|
||||
client,
|
||||
clientId,
|
||||
getTerminalRef,
|
||||
markNativeChatInputLeaseReady,
|
||||
scheduleDelayedAction,
|
||||
|
||||
@@ -146,8 +146,8 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('useHostClient', () => {
|
||||
it('rebinds when Expo reuses a screen between two connected cached hosts', async () => {
|
||||
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
|
||||
it('rebinds the client and its authenticated identity together across cached hosts', async () => {
|
||||
const host2 = { ...HOST, id: 'host-2', name: 'Host 2', deviceToken: 'token-2' }
|
||||
const client1 = makeFakeClient('connected')
|
||||
const client2 = makeFakeClient('connected')
|
||||
connectMock.mockReturnValueOnce(client1).mockReturnValueOnce(client2)
|
||||
@@ -155,11 +155,13 @@ describe('useHostClient', () => {
|
||||
|
||||
let selectedHostId = HOST.id
|
||||
let selectedClient: RpcClient | null = null
|
||||
let selectedClientId: string | null = null
|
||||
let selectedState: ConnectionState = 'disconnected'
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
function Probe(): null {
|
||||
const selected = useHostClient(selectedHostId)
|
||||
selectedClient = selected.client
|
||||
selectedClientId = selected.clientId
|
||||
selectedState = selected.state
|
||||
useHostClient(host2.id)
|
||||
return null
|
||||
@@ -171,16 +173,18 @@ describe('useHostClient', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(selectedClient).toBe(client1)
|
||||
expect(selectedClientId).toBe(HOST.deviceToken)
|
||||
expect(selectedState).toBe('connected')
|
||||
|
||||
selectedHostId = host2.id
|
||||
client2.emitState('disconnected')
|
||||
await act(async () => {
|
||||
client2.emitState('disconnected')
|
||||
renderer?.update(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(selectedClient).toBe(client2)
|
||||
expect(selectedClientId).toBe(host2.deviceToken)
|
||||
expect(selectedState).toBe('disconnected')
|
||||
expect(connectMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
@@ -35,6 +34,15 @@ import {
|
||||
import type { ConnectionState, HostProfile } from './types'
|
||||
import type { RpcClientContextValue } from './rpc-client-context-contract'
|
||||
|
||||
export {
|
||||
useDisconnectHostClient,
|
||||
useForceReconnect,
|
||||
useForgetHostClient,
|
||||
useHostClient,
|
||||
usePrimeHosts,
|
||||
useRefreshHostClient
|
||||
} from './host-client-hooks'
|
||||
|
||||
type StoreEntry = HostClientStoreEntry
|
||||
|
||||
const Ctx = createContext<RpcClientContextValue | null>(null)
|
||||
@@ -364,99 +372,3 @@ export function useRpcClientContext(): RpcClientContextValue {
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change.
|
||||
export function useHostClient(hostId: string | undefined): {
|
||||
client: RpcClient | null
|
||||
state: ConnectionState
|
||||
} {
|
||||
const ctx = useRpcClientContext()
|
||||
const [, force] = useState(0)
|
||||
// Why: an absent entry at mount is almost always the open racing the render, not a
|
||||
// dead host — seed amber; a failed open notifies 'disconnected' moments later.
|
||||
const [state, setState] = useState<ConnectionState>(() =>
|
||||
hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected'
|
||||
)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const clientHostIdRef = useRef<string | undefined>(hostId)
|
||||
const acquisitionRef = useRef<HostClientAcquisition>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
clientRef.current = null
|
||||
clientHostIdRef.current = undefined
|
||||
setState('disconnected')
|
||||
return
|
||||
}
|
||||
clientHostIdRef.current = hostId
|
||||
let cancelled = false
|
||||
// Subscribe before acquire so any state change during open is captured.
|
||||
const unsub = ctx.subscribeHostState(hostId, (next) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setState(next)
|
||||
// Why: async open and forceReconnect swap the client object; re-read each state change so screens never drive a stale one.
|
||||
const found = ctx.getAllClients().find((entry) => entry.hostId === hostId)
|
||||
if (found && found.client !== clientRef.current) {
|
||||
clientRef.current = found.client
|
||||
force((n) => n + 1)
|
||||
} else if (!found && clientRef.current) {
|
||||
// Why: disconnect/forget deletes the entry; never retain a dead client (STA-1511).
|
||||
clientRef.current = null
|
||||
force((n) => n + 1)
|
||||
}
|
||||
})
|
||||
const initial = ctx.acquire(hostId, acquisitionRef.current)
|
||||
clientRef.current = initial
|
||||
setState(ctx.getKnownState(hostId) ?? 'connecting')
|
||||
if (initial) {
|
||||
// Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client.
|
||||
force((n) => n + 1)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsub()
|
||||
ctx.release(hostId, acquisitionRef.current)
|
||||
clientRef.current = null
|
||||
clientHostIdRef.current = undefined
|
||||
}
|
||||
}, [ctx, hostId])
|
||||
|
||||
// Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render.
|
||||
const bound = clientHostIdRef.current === hostId
|
||||
const boundState = bound
|
||||
? state
|
||||
: hostId
|
||||
? (ctx.getKnownState(hostId) ?? 'connecting')
|
||||
: 'disconnected'
|
||||
return { client: bound ? clientRef.current : null, state: boundState }
|
||||
}
|
||||
|
||||
// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it.
|
||||
export function useRefreshHostClient(): (hostId: string) => void {
|
||||
const ctx = useRpcClientContext()
|
||||
return ctx.refreshHostClient
|
||||
}
|
||||
|
||||
export function useForgetHostClient(): (hostId: string) => void {
|
||||
const ctx = useRpcClientContext()
|
||||
return ctx.forgetHostClient
|
||||
}
|
||||
|
||||
export function useDisconnectHostClient(): (hostId: string) => void {
|
||||
const ctx = useRpcClientContext()
|
||||
return ctx.disconnectHostClient
|
||||
}
|
||||
|
||||
// Why: future-proof "Connection issues — try again" affordance.
|
||||
export function useForceReconnect(): (hostId: string) => Promise<void> {
|
||||
const ctx = useRpcClientContext()
|
||||
return ctx.forceReconnect
|
||||
}
|
||||
|
||||
// Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start.
|
||||
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
|
||||
const ctx = useRpcClientContext()
|
||||
return ctx.primeHosts
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ export function createHostClientSelectors(
|
||||
return {
|
||||
getKnownState,
|
||||
getState: (hostId: string): ConnectionState => getKnownState(hostId) ?? 'disconnected',
|
||||
getClientId: (hostId: string): string | null => entries.get(hostId)?.clientId ?? null,
|
||||
getReconnectAttempt: (hostId: string): number =>
|
||||
entries.get(hostId)?.client.getReconnectAttempt() ?? 0,
|
||||
getLastConnectedAt: (hostId: string): number | null =>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState, HostProfile } from './types'
|
||||
import type { HostClientAcquisition } from './host-client-acquisition-registry'
|
||||
import { useRpcClientContext } from './client-context'
|
||||
|
||||
// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change.
|
||||
export function useHostClient(hostId: string | undefined): {
|
||||
client: RpcClient | null
|
||||
clientId: string | null
|
||||
state: ConnectionState
|
||||
} {
|
||||
const ctx = useRpcClientContext()
|
||||
const [, force] = useState(0)
|
||||
const [state, setState] = useState<ConnectionState>(() =>
|
||||
hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected'
|
||||
)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const clientHostIdRef = useRef<string | undefined>(hostId)
|
||||
const acquisitionRef = useRef<HostClientAcquisition>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
clientRef.current = null
|
||||
clientHostIdRef.current = undefined
|
||||
setState('disconnected')
|
||||
return
|
||||
}
|
||||
clientHostIdRef.current = hostId
|
||||
let cancelled = false
|
||||
const unsub = ctx.subscribeHostState(hostId, (next) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setState(next)
|
||||
const found = ctx.getAllClients().find((entry) => entry.hostId === hostId)
|
||||
if (found && found.client !== clientRef.current) {
|
||||
clientRef.current = found.client
|
||||
force((n) => n + 1)
|
||||
} else if (!found && clientRef.current) {
|
||||
clientRef.current = null
|
||||
force((n) => n + 1)
|
||||
}
|
||||
})
|
||||
const initial = ctx.acquire(hostId, acquisitionRef.current)
|
||||
clientRef.current = initial
|
||||
setState(ctx.getKnownState(hostId) ?? 'connecting')
|
||||
if (initial) {
|
||||
force((n) => n + 1)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsub()
|
||||
ctx.release(hostId, acquisitionRef.current)
|
||||
clientRef.current = null
|
||||
clientHostIdRef.current = undefined
|
||||
}
|
||||
}, [ctx, hostId])
|
||||
|
||||
const bound = clientHostIdRef.current === hostId
|
||||
const boundClient = bound ? clientRef.current : null
|
||||
const boundState = bound
|
||||
? state
|
||||
: hostId
|
||||
? (ctx.getKnownState(hostId) ?? 'connecting')
|
||||
: 'disconnected'
|
||||
return {
|
||||
client: boundClient,
|
||||
clientId: boundClient && hostId ? ctx.getClientId(hostId) : null,
|
||||
state: boundState
|
||||
}
|
||||
}
|
||||
|
||||
export function useRefreshHostClient(): (hostId: string) => void {
|
||||
return useRpcClientContext().refreshHostClient
|
||||
}
|
||||
|
||||
export function useForgetHostClient(): (hostId: string) => void {
|
||||
return useRpcClientContext().forgetHostClient
|
||||
}
|
||||
|
||||
export function useDisconnectHostClient(): (hostId: string) => void {
|
||||
return useRpcClientContext().disconnectHostClient
|
||||
}
|
||||
|
||||
export function useForceReconnect(): (hostId: string) => Promise<void> {
|
||||
return useRpcClientContext().forceReconnect
|
||||
}
|
||||
|
||||
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
|
||||
return useRpcClientContext().primeHosts
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { ConnectionState, HostProfile } from './types'
|
||||
|
||||
export type HostClientStoreEntry = {
|
||||
client: RpcClient
|
||||
clientId: string
|
||||
state: ConnectionState
|
||||
refCount: number
|
||||
unsubState: () => void
|
||||
@@ -130,6 +131,7 @@ export async function openHostClientEntry(
|
||||
}) ?? (() => {})
|
||||
const entry: HostClientStoreEntry = {
|
||||
client,
|
||||
clientId: host.deviceToken,
|
||||
state: client.getState(),
|
||||
refCount: state.pendingAcquisitions.get(hostId) ?? 0,
|
||||
unsubState,
|
||||
|
||||
@@ -18,6 +18,7 @@ export type RpcClientContextValue = {
|
||||
disconnectHostClient: (hostId: string) => void
|
||||
getState: (hostId: string) => ConnectionState
|
||||
getKnownState: (hostId: string) => ConnectionState | null
|
||||
getClientId: (hostId: string) => string | null
|
||||
getReconnectAttempt: (hostId: string) => number
|
||||
getLastConnectedAt: (hostId: string) => number | null
|
||||
getActivePath: (hostId: string) => MobileConnectionPath
|
||||
|
||||
@@ -7,14 +7,14 @@ import { matchesSettingsSearch } from './settings-search'
|
||||
// Native word for "language" in each supported UI language. These must be
|
||||
// findable no matter which locale the interface is currently rendered in, so a
|
||||
// speaker can locate (and switch to) their language from any starting point.
|
||||
const NATIVE_LANGUAGE_WORDS = ['语言', '語言', '언어', '言語', 'Idioma']
|
||||
const NATIVE_LANGUAGE_WORDS = ['语言', '語言', '언어', '言語', 'Idioma', 'Langue']
|
||||
|
||||
describe('getLanguageEntries', () => {
|
||||
afterEach(async () => {
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
it.each(['en', 'zh', 'ko', 'ja', 'es'])(
|
||||
it.each(['en', 'zh', 'ko', 'ja', 'es', 'fr'])(
|
||||
'indexes every native word for "language" under the %s UI locale',
|
||||
async (locale) => {
|
||||
await i18n.changeLanguage(locale)
|
||||
@@ -29,4 +29,9 @@ describe('getLanguageEntries', () => {
|
||||
await i18n.changeLanguage('en')
|
||||
expect(matchesSettingsSearch('Español', getLanguageEntries()[0])).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the French native language name in English UI', async () => {
|
||||
await i18n.changeLanguage('en')
|
||||
expect(matchesSettingsSearch('Français', getLanguageEntries()[0])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,6 +50,7 @@ export const getLanguageEntries = createLocalizedCatalog((): SettingsSearchEntry
|
||||
...translateSearchKeyword('settings.appearance.language.korean', '한국어'),
|
||||
...translateSearchKeyword('settings.appearance.language.japanese', '日本語'),
|
||||
...translateSearchKeyword('settings.appearance.language.spanish', 'Español'),
|
||||
...translateSearchKeyword('settings.appearance.language.french', 'Français'),
|
||||
// Why: the native word for "language" only reaches search via the localized
|
||||
// title in its own UI locale — index each here so speakers can find (and
|
||||
// switch to) their language whatever the current interface locale is.
|
||||
@@ -58,6 +59,7 @@ export const getLanguageEntries = createLocalizedCatalog((): SettingsSearchEntry
|
||||
'언어', // Korean
|
||||
'言語', // Japanese
|
||||
'Idioma', // Spanish
|
||||
'Langue', // French
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.appearance.search.language.locale',
|
||||
'locale'
|
||||
|
||||
@@ -2934,7 +2934,6 @@
|
||||
"a7e2fd2699": "signaler un problème",
|
||||
"5c8ce20be6": "Si le problème persiste, veuillez",
|
||||
"cc6d997c65": "Redémarrez le daemon de terminal depuis ici pour effacer un état de daemon obsolète.",
|
||||
"7ee11bc0db": "Orca n'a pas pu confirmer si la session précédente de ce terminal tourne encore ; il a donc laissé la session intacte. Rouvrez ce volet pour réessayer.",
|
||||
"e16012e31e": "Le daemon de terminal propriétaire de cette session s'est arrêté ; la session et son historique de défilement n'ont pas pu être récupérés. Ouvrez un nouveau terminal pour continuer.",
|
||||
"sessionUnavailable": "Orca n'a pas pu se rattacher à la session de terminal de ce volet sur l'hôte. Ouvrez un nouveau terminal pour continuer."
|
||||
},
|
||||
@@ -3228,7 +3227,6 @@
|
||||
"25dc1cd653": "Ouvrir le fichier",
|
||||
"7cdf8ee0c8": "Ouvrir une URL",
|
||||
"b27864279e": "Lancer un agent",
|
||||
"0e5b7a3f16": "Rechercher parmi les onglets ouverts, fichiers, URL et agents…",
|
||||
"8f0a1c4d92": "Basculer vers l'onglet",
|
||||
"2c38630a01": "L'espace de travail n'existe plus",
|
||||
"4f0d9a71c2": "L'onglet n'existe plus",
|
||||
@@ -3274,7 +3272,6 @@
|
||||
"classifier": {
|
||||
"42e6262ae9": "Aucune action disponible.",
|
||||
"097a982ee0": "Chargement des fichiers...",
|
||||
"c41f8d20b7": "Rechercher parmi les onglets ouverts, fichiers, URL et agents…",
|
||||
"90eb94dc48": "Saisissez une URL http:// ou https://.",
|
||||
"5553b283ce": "Saisissez une URL ou un chemin de fichier.",
|
||||
"queryTooLarge": "Le texte recherché est trop long.",
|
||||
@@ -6999,12 +6996,6 @@
|
||||
"compareBaseRepositoryDefault": "Valeur par défaut du dépôt",
|
||||
"compareBaseBranchUpstream": "Amont de la branche"
|
||||
},
|
||||
"HiddenExperimentalGroup": {
|
||||
"d0f914a528": "Bascule fictive",
|
||||
"1014ddbfaf": "Sans effet aujourd'hui. Réservée comme premier emplacement pour les options expérimentales masquées.",
|
||||
"232cf83de8": "Bascules non répertoriées pour les tests internes. Rien ici n'est pris en charge.",
|
||||
"3e9e827ca5": "Expérimental masqué"
|
||||
},
|
||||
"InputPane": {
|
||||
"db15068196": "Activé par défaut sur Linux et macOS. Linux utilise le presse-papiers de sélection du système ; les autres plateformes utilisent un tampon privé.",
|
||||
"ad31c3c5fb": "Collage de la sélection au clic milieu"
|
||||
@@ -11832,10 +11823,6 @@
|
||||
"b3c8f1a902": "Filtrer les fichiers par nom",
|
||||
"d4f8c2a901": "Effacer et fermer le filtre",
|
||||
"e8a1c4b203": "vs",
|
||||
"f9b2441bb6": "1 commit d'avance sur {{value0}}",
|
||||
"b715ef615b": "{{value0}} commits d'avance sur {{value1}}",
|
||||
"c1a8f3e204": "1 commit de retard sur {{value0}}",
|
||||
"d2b9g4f315": "{{value0}} commits de retard sur {{value1}}",
|
||||
"4b4a7de138": "Ouvrir la page de revue dans le navigateur",
|
||||
"createPrIntentCommitBlockedSummary": "Commit bloqué : {{value0}} Corrigez le problème, puis réessayez Créer une PR.",
|
||||
"pushRecovery": {
|
||||
@@ -11858,7 +11845,6 @@
|
||||
"a4e93c21d7": "Branche actuelle : {{value0}}",
|
||||
"c7d4e2f801": "Modifier la ref de base : {{value0}}",
|
||||
"f3a1b8c204": "upstream",
|
||||
"createPrIntentEmptyGeneratedBody": "Les détails de revue générés n'incluent pas de description. Réessayez Créer une PR.",
|
||||
"createPrIntentGenerateDetailsFailed": "Impossible de générer les détails de revue. Réessayez Créer une PR."
|
||||
},
|
||||
"SourceControlAgentActionDialog": {
|
||||
@@ -14038,8 +14024,6 @@
|
||||
"7e7ca60816": "fichiers modifiés",
|
||||
"b6c3b84476": "Afficher l'arborescence des fichiers",
|
||||
"39f8007549": "Examiner les conflits",
|
||||
"39e73e7181": "ont été exclus de cette vue de diff.",
|
||||
"689b99f8ad": "conflit non résolu",
|
||||
"820ec01f24": "Les fichiers en conflit sont examinés séparément",
|
||||
"fd8892b120": "Aucune modification à afficher",
|
||||
"eb5f40e49c": "Cette vue de diff exclut les conflits non résolus, car le pipeline de diff bidirectionnel habituel n'est pas conçu pour gérer les conflits.",
|
||||
@@ -15793,7 +15777,6 @@
|
||||
},
|
||||
"LinuxPackageInstallRecoveryCard": {
|
||||
"e3de29c86a": "Afficher le paquet",
|
||||
"3da99454c6": "Réessayer l'installation automatique",
|
||||
"55c86654b7": "Copier la commande d'installation",
|
||||
"53e1559f99": "Échec de l'installation automatique",
|
||||
"a7ac6ec78b": "Orca a téléchargé la mise à jour mais n'a pas pu installer le paquet système automatiquement.",
|
||||
|
||||
Reference in New Issue
Block a user