fix(ui): preserve VS Code themes during settings broadcasts

This commit is contained in:
Bohdan Triapitsyn
2026-09-03 12:41:59 +03:00
141 changed files with 3435 additions and 286 deletions
+6
View File
@@ -147,6 +147,11 @@ export interface GitStatus {
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
}
export interface GitUnpushedBranchCounts {
/** Local commits not present in each branch's configured upstream. */
counts: Record<string, number>;
}
export interface GitDiffResponse {
diff: string;
}
@@ -505,6 +510,7 @@ export interface GitAPI {
revertGitHunk?(directory: string, filePath: string, patch: string): Promise<void>;
isLinkedWorktree(directory: string): Promise<boolean>;
getGitBranches(directory: string): Promise<GitBranch>;
getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise<GitUnpushedBranchCounts>;
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }>;
+81 -8
View File
@@ -2,19 +2,52 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
let apiBaseUrl = 'https://remote.example.test';
let tunnelResult: unknown = { localPort: 52418, reused: false };
type TunnelResult = { localPort: number; reused: boolean } | Error;
type DesktopTunnelArgs = { baseUrl?: string; port?: number; relay?: boolean; targetKey?: string };
type RelayEvent = { connectionId: string; remotePort: number; message: { type: string; data?: ArrayBuffer } };
type RelaySocketFixture = {
binaryType: string;
onopen: (() => void) | null;
onmessage: ((event: { data: ArrayBuffer | string }) => void) | null;
onerror: (() => void) | null;
onclose: (() => void) | null;
send: ReturnType<typeof mock>;
close: ReturnType<typeof mock>;
readyState: number;
};
let tunnelResult: TunnelResult = { localPort: 52418, reused: false };
let desktopArgs: DesktopTunnelArgs | undefined;
let relayActive = false;
let openedRelayUrl = '';
let refreshedBaseUrl = '';
let refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; };
let relayHandler: ((event: RelayEvent) => void) | null = null;
const relayPosts: Array<{ connectionId: string; message: { type: string; data?: ArrayBuffer } }> = [];
const relaySocket: RelaySocketFixture = { binaryType: 'arraybuffer', onopen: null, onmessage: null, onerror: null, onclose: null, send: mock(() => {}), close: mock(() => {}), readyState: 0 };
mock.module('@/lib/desktopNative', () => ({
invokeDesktopCommand: mock(async () => {
invokeDesktopCommand: mock(async (_command: string, args?: DesktopTunnelArgs) => {
desktopArgs = args;
if (tunnelResult instanceof Error) throw tunnelResult;
return tunnelResult;
}),
listenForDesktopRelayDevTunnels: (handler: typeof relayHandler) => { relayHandler = handler; return true; },
postDesktopRelayDevTunnelMessage: (connectionId: string, message: { type: string; data?: ArrayBuffer }) => relayPosts.push({ connectionId, message }),
}));
mock.module('@/lib/relay/runtime-tunnel', () => ({
isRelayModeActive: () => relayActive,
getActiveRelayTunnel: () => relayActive ? {} : null,
}));
mock.module('@/lib/relay/runtime-socket', () => ({ openRuntimeWebSocket: (url: string) => { openedRelayUrl = url; return relaySocket; } }));
mock.module('@/lib/runtime-auth', () => ({
getRuntimeBearerTokenSync: () => 'token',
getRuntimeExtraHeadersSync: () => ({}),
refreshRuntimeUrlAuthToken: (baseUrl: string) => refreshUrlAuth(baseUrl),
}));
mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => ({ websocket: (path: string) => `openchamber-ui://app${path}&oc_url_token=test` }) }));
mock.module('@/lib/runtime-switch', () => ({
getRuntimeApiBaseUrl: () => apiBaseUrl,
getRuntimeKey: () => relayActive ? 'host:exe' : `url:${apiBaseUrl}`,
subscribeRuntimeEndpointChanged: () => () => {},
}));
@@ -25,23 +58,30 @@ const {
toDisplayUrl,
} = await import('./devTunnel');
const globalScope = globalThis as unknown as { window?: unknown };
const asDesktop = (value: boolean) => {
globalScope.window = value
? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } }
: { location: { href: 'http://127.0.0.1:3901/' } };
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: value
? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } }
: { location: { href: 'http://127.0.0.1:3901/' } },
});
};
describe('loopback navigations against a remote instance', () => {
beforeEach(() => {
apiBaseUrl = 'https://remote.example.test';
tunnelResult = { localPort: 52418, reused: false };
desktopArgs = undefined;
relayActive = false;
relayPosts.length = 0;
openedRelayUrl = '';
refreshedBaseUrl = '';
refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; };
asDesktop(true);
});
afterEach(() => {
delete globalScope.window;
Reflect.deleteProperty(globalThis, 'window');
});
test('a page reached through a tunnel keeps its other ports on the host', () => {
@@ -82,6 +122,39 @@ describe('loopback navigations against a remote instance', () => {
expect(failed).toBe(true);
});
test('a relay-only runtime asks Electron for a local relay bridge', async () => {
relayActive = true;
apiBaseUrl = 'openchamber-ui://app';
const resolved = await resolveBrowsableUrl('http://localhost:4322/docs/');
expect(resolved).toBe('http://127.0.0.1:52418/docs/');
expect(desktopArgs?.relay).toBe(true);
expect(desktopArgs?.targetKey).toBe('host:exe');
expect(desktopArgs?.port).toBe(4322);
relayHandler?.({ connectionId: 'connection-1', remotePort: 4322, message: { type: 'connect' } });
await Promise.resolve();
await Promise.resolve();
relaySocket.onopen?.();
expect(refreshedBaseUrl).toBe('openchamber-ui://app');
expect(openedRelayUrl).toContain('/api/dev-tunnel?port=4322&oc_url_token=test');
expect(relayPosts.some((entry) => entry.connectionId === 'connection-1' && entry.message.type === 'ready')).toBe(true);
});
test('a local disconnect during auth does not leave an orphan relay socket', async () => {
relayActive = true;
apiBaseUrl = 'openchamber-ui://app';
let finishAuth = () => {};
refreshUrlAuth = () => new Promise<string>((resolve) => { finishAuth = () => resolve('url-token'); });
relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'connect' } });
relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'close' } });
finishAuth();
await Promise.resolve();
await Promise.resolve();
expect(openedRelayUrl).toBe('');
});
test('a local instance resolves its own loopback correctly', () => {
apiBaseUrl = 'http://127.0.0.1:3901';
expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false);
+69 -14
View File
@@ -10,17 +10,66 @@
* Everywhere else — local runtime, web, mobile — the URL is already correct and
* is returned untouched.
*/
import { invokeDesktopCommand } from '@/lib/desktopNative';
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { invokeDesktopCommand, listenForDesktopRelayDevTunnels, postDesktopRelayDevTunnelMessage } from '@/lib/desktopNative';
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync, refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getActiveRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { openRuntimeWebSocket } from '@/lib/relay/runtime-socket';
import type { RelayTunnelWebSocket } from '@/lib/relay/tunnel-client';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { isLoopbackUrl } from './url';
type TunnelResult = { localPort: number; reused: boolean; url: string };
type TunnelResult = { localPort: number };
/** Keyed by `${baseUrl}|${port}`; the shell owns the real lifetime. */
const localPortByTarget = new Map<string, number>();
/** Reverse map, so a tunnel port never leaks into the address bar or storage. */
const originByLocalPort = new Map<number, string>();
const relaySockets = new Map<string, RelayTunnelWebSocket>();
const pendingRelayConnections = new Set<string>();
const openRelayConnection = async (connectionId: string, remotePort: number): Promise<void> => {
if (!getActiveRelayTunnel()) {
pendingRelayConnections.delete(connectionId);
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
return;
}
await refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl());
if (!pendingRelayConnections.has(connectionId) || !getActiveRelayTunnel()) return;
const url = getRuntimeUrlResolver().websocket(`/api/dev-tunnel?port=${remotePort}`);
const socket = openRuntimeWebSocket(url);
relaySockets.set(connectionId, socket);
socket.binaryType = 'arraybuffer';
socket.onopen = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'ready' });
socket.onmessage = (event) => postDesktopRelayDevTunnelMessage(connectionId, { type: 'data', data: event.data instanceof ArrayBuffer ? event.data : new TextEncoder().encode(event.data) });
socket.onerror = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
socket.onclose = () => {
pendingRelayConnections.delete(connectionId);
relaySockets.delete(connectionId);
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
};
};
listenForDesktopRelayDevTunnels(({ connectionId, remotePort, message }) => {
switch (message.type) {
case 'data': {
const socket = relaySockets.get(connectionId);
if (socket && message.data) socket.send(message.data);
return;
}
case 'close':
pendingRelayConnections.delete(connectionId);
relaySockets.get(connectionId)?.close();
relaySockets.delete(connectionId);
return;
case 'connect':
pendingRelayConnections.add(connectionId);
void openRelayConnection(connectionId, remotePort).catch(() => {
pendingRelayConnections.delete(connectionId);
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
});
}
});
const isDesktopRuntime = (): boolean => (
typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__)
@@ -69,6 +118,14 @@ const rewriteToLocalPort = (url: string, localPort: number): string => {
}
};
const rememberOriginalOrigin = (url: string, localPort: number): void => {
try {
originByLocalPort.set(localPort, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
};
/** Thrown when a remote dev server exists but could not be reached from here. */
export class DevTunnelUnavailableError extends Error {
constructor(message: string) {
@@ -100,11 +157,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
const key = `${baseUrl}|${port}`;
const cached = localPortByTarget.get(key);
if (cached) {
try {
originByLocalPort.set(cached, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
rememberOriginalOrigin(url, cached);
return rewriteToLocalPort(url, cached);
}
@@ -112,6 +165,8 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
const result = await invokeDesktopCommand<TunnelResult>('desktop_dev_tunnel_open', {
baseUrl,
port,
relay: isRelayModeActive(),
targetKey: getRuntimeKey(),
clientToken: getRuntimeBearerTokenSync(),
requestHeaders: getRuntimeExtraHeadersSync(),
});
@@ -119,11 +174,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
throw new DevTunnelUnavailableError(url);
}
localPortByTarget.set(key, result.localPort);
try {
originByLocalPort.set(result.localPort, new URL(url).origin);
} catch {
// Unparseable input never reaches here; nothing to record.
}
rememberOriginalOrigin(url, result.localPort);
return rewriteToLocalPort(url, result.localPort);
} catch (error) {
if (error instanceof DevTunnelUnavailableError) throw error;
@@ -180,6 +231,10 @@ export const toDisplayUrl = (url: string): string => {
const resetDevTunnelCache = (): void => {
localPortByTarget.clear();
originByLocalPort.clear();
pendingRelayConnections.clear();
for (const socket of relaySockets.values()) socket.close();
relaySockets.clear();
void invokeDesktopCommand('desktop_relay_dev_tunnel_close_all').catch(() => {});
};
if (typeof window !== 'undefined') {
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { DesktopHost, HostProbeResult } from './desktopHosts';
let probeResults: Record<string, HostProbeResult> = {};
const probeCalls: string[] = [];
let probeGate: Promise<void> | null = null;
const desktopModule = await import('./desktopHosts');
mock.module('./desktopHosts', () => ({
...desktopModule,
desktopLocalClientTokenGet: async () => 'local-token',
desktopHostProbe: async (url: string) => {
probeCalls.push(url);
if (probeGate) await probeGate;
return probeResults[url] ?? { status: 'unreachable', latencyMs: 0 };
},
}));
const desktopShell = await import('@/lib/desktop');
mock.module('@/lib/desktop', () => ({
...desktopShell,
isDesktopShell: () => true,
isElectronShell: () => false,
}));
const {
getDesktopHostStatusSnapshot,
probeDesktopHosts,
pruneDesktopHostStatuses,
setDesktopHostStatus,
subscribeDesktopHostStatuses,
} = await import('./desktopHostStatus');
const host = (id: string, url: string): DesktopHost => ({ id, label: id, url });
describe('desktop host statuses', () => {
beforeEach(() => {
probeResults = {};
probeCalls.length = 0;
probeGate = null;
pruneDesktopHostStatuses([]);
setDesktopHostStatus('local', { status: 'ok', latencyMs: 1 });
pruneDesktopHostStatuses([]);
});
test('a probe replaces the previous value instead of blanking it first', async () => {
setDesktopHostStatus('remote', { status: 'ok', latencyMs: 12 });
probeResults['https://remote.example'] = { status: 'ok', latencyMs: 40 };
const seen: Array<string | undefined> = [];
const unsubscribe = subscribeDesktopHostStatuses(() => {
seen.push(getDesktopHostStatusSnapshot().byHostId.remote?.status);
});
await probeDesktopHosts([host('remote', 'https://remote.example')]);
unsubscribe();
// Every published snapshot during the run still carried a status; the row
// never falls back to "Checking" while a quiet refresh is running.
expect(seen.every((status) => status !== undefined)).toBe(true);
expect(getDesktopHostStatusSnapshot().byHostId.remote?.latencyMs).toBe(40);
});
test('a fast host is published while a slow one is still in flight', async () => {
probeResults['https://fast.example'] = { status: 'ok', latencyMs: 5 };
probeResults['https://slow.example'] = { status: 'ok', latencyMs: 900 };
let releaseSlow!: () => void;
const slowGate = new Promise<void>((resolve) => { releaseSlow = resolve; });
probeGate = slowGate;
const run = probeDesktopHosts([host('fast', 'https://fast.example'), host('slow', 'https://slow.example')]);
await Promise.resolve();
expect(getDesktopHostStatusSnapshot().isProbing).toBe(true);
releaseSlow();
await run;
expect(getDesktopHostStatusSnapshot().byHostId.fast?.status).toBe('ok');
expect(getDesktopHostStatusSnapshot().byHostId.slow?.status).toBe('ok');
expect(getDesktopHostStatusSnapshot().isProbing).toBe(false);
});
test('pruning keeps local and every configured instance, and forgets the rest', () => {
setDesktopHostStatus('kept', { status: 'ok', latencyMs: 3 });
setDesktopHostStatus('removed', { status: 'ok', latencyMs: 4 });
pruneDesktopHostStatuses(['kept']);
const { byHostId } = getDesktopHostStatusSnapshot();
expect(byHostId.kept?.status).toBe('ok');
expect(byHostId.local?.status).toBe('ok');
expect(byHostId.removed).toBe(undefined);
});
test('a snapshot is a new object per change so subscribers re-render', () => {
const before = getDesktopHostStatusSnapshot();
setDesktopHostStatus('remote', { status: 'auth', latencyMs: 0 });
expect(getDesktopHostStatusSnapshot()).not.toBe(before);
expect(before.byHostId.remote).toBe(undefined);
});
test('a slow older run cannot overwrite a newer result', async () => {
// Startup warm-up, opening the switcher and the refresh button all probe;
// whichever finishes last must not be whichever started first.
probeResults['https://remote.example'] = { status: 'unreachable', latencyMs: 0 };
let releaseSlow!: () => void;
probeGate = new Promise<void>((resolve) => { releaseSlow = resolve; });
const slowRun = probeDesktopHosts([host('remote', 'https://remote.example')]);
await Promise.resolve();
probeGate = null;
probeResults['https://remote.example'] = { status: 'ok', latencyMs: 30 };
await probeDesktopHosts([host('remote', 'https://remote.example')]);
expect(getDesktopHostStatusSnapshot().byHostId.remote?.status).toBe('ok');
releaseSlow();
await slowRun;
expect(getDesktopHostStatusSnapshot().byHostId.remote?.status).toBe('ok');
expect(getDesktopHostStatusSnapshot().byHostId.remote?.latencyMs).toBe(30);
});
test('a status recorded by the switch flow outranks a probe already running', async () => {
probeResults['https://remote.example'] = { status: 'unreachable', latencyMs: 0 };
let releaseSlow!: () => void;
probeGate = new Promise<void>((resolve) => { releaseSlow = resolve; });
const slowRun = probeDesktopHosts([host('remote', 'https://remote.example')]);
await Promise.resolve();
setDesktopHostStatus('remote', { status: 'ok', latencyMs: 7, via: 'relay' });
releaseSlow();
await slowRun;
expect(getDesktopHostStatusSnapshot().byHostId.remote?.status).toBe('ok');
});
});
+186
View File
@@ -0,0 +1,186 @@
import { isDesktopShell, isElectronShell } from '@/lib/desktop';
import {
desktopHostProbe,
desktopHostsGet,
desktopLocalClientTokenGet,
getDesktopHostApiUrl,
normalizeHostUrl,
probeRelayDesktopHost,
type DesktopHost,
type HostProbeResult,
} from '@/lib/desktopHosts';
import { LOCAL_HOST_ID, buildLocalDesktopHost } from '@/lib/desktopCurrentHost';
export type DesktopHostStatus = {
status: HostProbeResult['status'];
latencyMs: number;
/** Which transport the successful probe used (multi-transport hosts). */
via?: 'relay';
};
/** Reachability by instance id. */
type DesktopHostStatusMap = Record<string, DesktopHostStatus>;
type DesktopHostStatusSnapshot = {
byHostId: Readonly<DesktopHostStatusMap>;
/** True while any probe run is in flight, for the refresh spinner. */
isProbing: boolean;
};
/**
* Reachability of every configured instance, owned outside the switcher UI.
*
* The switcher used to hold this in component state, which made the dropdown
* the only thing that could ever learn an instance's status: every open started
* from nothing and showed "Checking" on rows the app had already answered for —
* including the instance the app was connected to and actively talking to.
*
* Keeping it here lets startup warm the statuses before the user opens
* anything, and lets a re-probe replace values in place instead of blanking
* them first.
*/
const statuses = new Map<string, DesktopHostStatus>();
// Startup warm-up, opening the switcher and the refresh button can all be in
// flight at once, and a probe's duration varies by an order of magnitude
// between a loopback host and a relay host working through tunnel retries.
// Without ordering, a slow older run lands last and replaces a fresh "ok" with
// its own stale "unreachable". Each host remembers which run owns its status.
let probeRunSequence = 0;
const owningRunByHostId = new Map<string, number>();
let activeProbeRuns = 0;
let snapshot: DesktopHostStatusSnapshot = { byHostId: {}, isProbing: false };
const listeners = new Set<() => void>();
const publishSnapshot = (): void => {
// `useSyncExternalStore` compares snapshots by identity, so each mutation
// publishes a fresh one rather than handing out the live map.
snapshot = { byHostId: Object.fromEntries(statuses), isProbing: activeProbeRuns > 0 };
for (const listener of listeners) {
try {
listener();
} catch {
// A subscriber throwing must not stop the others.
}
}
};
export const subscribeDesktopHostStatuses = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
export const getDesktopHostStatusSnapshot = (): DesktopHostStatusSnapshot => snapshot;
const setStatus = (hostId: string, status: DesktopHostStatus): void => {
statuses.set(hostId, status);
publishSnapshot();
};
/**
* Record a status learned outside a probe run — the switch flow probes too, and
* its result is the freshest thing anyone has, so it takes ownership away from
* any probe run still running for that host.
*/
export const setDesktopHostStatus = (hostId: string, status: DesktopHostStatus): void => {
owningRunByHostId.set(hostId, ++probeRunSequence);
setStatus(hostId, status);
};
/**
* Forget instances that are no longer configured. Called with the authoritative
* host list, never with a partially loaded one — dropping entries on a list
* that has not finished loading is what made every dropdown open start blank.
*/
export const pruneDesktopHostStatuses = (configuredHostIds: readonly string[]): void => {
const keep = new Set([LOCAL_HOST_ID, ...configuredHostIds]);
let changed = false;
for (const hostId of Array.from(statuses.keys())) {
if (keep.has(hostId)) continue;
statuses.delete(hostId);
owningRunByHostId.delete(hostId);
changed = true;
}
if (changed) publishSnapshot();
};
const isBlockedProbeStatus = (status: HostProbeResult['status']): boolean =>
status === 'unreachable' || status === 'wrong-service' || status === 'incompatible';
const getLocalClientToken = async (): Promise<string> => {
if (!isElectronShell()) return '';
return desktopLocalClientTokenGet().catch(() => '');
};
const probeHost = async (host: DesktopHost, localClientToken: string): Promise<DesktopHostStatus> => {
const clientToken = host.id === LOCAL_HOST_ID ? localClientToken : (host.clientToken || '');
const probeRelayLeg = async (): Promise<DesktopHostStatus> => {
const res = await probeRelayDesktopHost(host.relay!, { clientToken, requestHeaders: host.requestHeaders || null })
.catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const status: DesktopHostStatus = { status: res.status, latencyMs: res.latencyMs };
// `via` is what renders the "· Relay" suffix, so it marks a reachable host
// only — a failed relay leg says nothing about which transport would work.
if (res.status === 'ok') status.via = 'relay';
return status;
};
// Relay-only host: no HTTP address — probe through the E2EE tunnel.
if (host.relay && !host.apiUrl) return probeRelayLeg();
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(host) : host.url);
if (!url) return { status: 'unreachable', latencyMs: 0 };
const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null })
.catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
// Multi-transport host away from its network: the direct leg fails but the
// relay may still reach it.
if (isBlockedProbeStatus(res.status) && host.relay) {
const relayStatus = await probeRelayLeg();
if (relayStatus.status === 'ok') return relayStatus;
}
return { status: res.status, latencyMs: res.latencyMs };
};
/**
* Probe every given instance, publishing each result the moment it lands.
* Waiting for the slowest probe would hold answered rows on "Checking" beside
* one host still working through its relay tunnel retries.
*/
export const probeDesktopHosts = async (hosts: readonly DesktopHost[]): Promise<void> => {
if (!isDesktopShell()) return;
const run = ++probeRunSequence;
for (const host of hosts) owningRunByHostId.set(host.id, run);
activeProbeRuns += 1;
publishSnapshot();
try {
const localClientToken = await getLocalClientToken();
await Promise.all(hosts.map(async (host) => {
const status = await probeHost(host, localClientToken);
// A newer run (or a switch) claimed this host while we were probing.
if (owningRunByHostId.get(host.id) !== run) return;
setStatus(host.id, status);
}));
} finally {
activeProbeRuns -= 1;
publishSnapshot();
}
};
let warmUpStarted = false;
/**
* Learn every instance's status once at startup, so the switcher opens on real
* values instead of probing for the first time under the user's cursor.
*
* Deliberately after the app's own bootstrap: this is background work, and the
* direct legs go through the Electron main process while relay legs open their
* own WebSocket, so neither shares the renderer's connection pool with session
* traffic — but the machine's network is still busiest right at launch.
*/
export const warmDesktopHostStatuses = async (): Promise<void> => {
if (warmUpStarted || !isDesktopShell()) return;
warmUpStarted = true;
const config = await desktopHostsGet().catch(() => null);
if (!config) return;
pruneDesktopHostStatuses(config.hosts.map((host) => host.id));
await probeDesktopHosts([buildLocalDesktopHost(config.localOrigin), ...config.hosts]);
};
+104 -2
View File
@@ -1,5 +1,24 @@
import { describe, expect, test } from 'bun:test';
import { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
import { describe, expect, mock, test } from 'bun:test';
import type { RelayTunnelStatus } from '@/lib/relay/tunnel-client';
import type { DesktopHostRelay } from './desktopHosts';
type TunnelStub = {
fetch: (path: string, init?: RequestInit) => Promise<Response>;
getStatus: () => RelayTunnelStatus;
close: () => void;
};
let nextTunnel: (() => TunnelStub) | null = null;
const tunnelModule = await import('@/lib/relay/tunnel-client');
mock.module('@/lib/relay/tunnel-client', () => ({
...tunnelModule,
createRelayTunnelClient: () => {
if (!nextTunnel) throw new Error('no tunnel stub registered');
return nextTunnel();
},
}));
const { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, probeRelayDesktopHost, redactSensitiveUrl, resolveDesktopHostUrl } = await import('./desktopHosts');
const withDesktopBridge = async <T>(handler: (cmd: string, args: Record<string, unknown>) => unknown | Promise<unknown>, run: () => Promise<T>): Promise<T> => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
@@ -121,3 +140,86 @@ describe('desktop host runtime headers', () => {
});
});
});
describe('probeRelayDesktopHost', () => {
const relay: DesktopHostRelay = {
relayUrl: 'wss://relay.example',
serverId: 'server-a',
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
};
const withTimerWindow = async <T>(run: () => Promise<T>): Promise<T> => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { setTimeout: setTimeout.bind(globalThis), clearTimeout: clearTimeout.bind(globalThis) },
});
try {
return await run();
} finally {
if (previousWindow) {
Object.defineProperty(globalThis, 'window', previousWindow);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
}
};
const stubTunnel = (
responses: Array<Response | Error>,
state: RelayTunnelStatus['state'] = 'reconnecting',
) => {
const calls: string[] = [];
let closed = false;
nextTunnel = () => ({
fetch: async (path) => {
calls.push(path);
const next = responses.shift();
if (!next) throw new Error('relay tunnel reset');
if (next instanceof Error) throw next;
return next;
},
getStatus: () => ({ state }),
close: () => { closed = true; },
});
return { calls, isClosed: () => closed };
};
test('a cold first attempt is retried instead of reported unreachable', async () => {
// The tunnel rejects waiters on its first failed connect and then
// reconnects; the probe must span that, not read it as an unreachable host.
const tunnel = stubTunnel([
new Error('relay tunnel reset: connection failed'),
new Response('{}', { status: 200 }),
new Response('{}', { status: 200 }),
]);
const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'token' }));
expect(result.status).toBe('ok');
expect(tunnel.calls).toEqual(['/health', '/health', '/auth/session']);
expect(tunnel.isClosed()).toBe(true);
});
test('a terminal tunnel state ends the probe without retrying', async () => {
// Auth failed / duplicate client / limit reached will not resolve by waiting.
const tunnel = stubTunnel([new Error('relay connection replaced by another client')], 'error');
const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'token' }));
expect(result.status).toBe('unreachable');
expect(tunnel.calls).toEqual(['/health']);
});
test('a rejected client token is reported as auth, not unreachable', async () => {
const tunnel = stubTunnel([
new Response('{}', { status: 200 }),
new Response('{}', { status: 401 }),
]);
const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'stale' }));
expect(result.status).toBe('auth');
expect(tunnel.calls).toEqual(['/health', '/auth/session']);
});
});
+51 -7
View File
@@ -408,14 +408,18 @@ export const desktopInstallIdGet = async (): Promise<string> => {
};
const RELAY_PROBE_TIMEOUT_MS = 8_000;
// Whole-probe budget, spanning the tunnel's own reconnect attempts.
const RELAY_PROBE_DEADLINE_MS = 15_000;
const RELAY_PROBE_RETRY_DELAY_MS = 400;
const fetchRelayProbe = async (
tunnel: ReturnType<typeof createRelayTunnelClient>,
path: string,
timeoutMs: number,
init?: RequestInit,
): Promise<Response> => {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), RELAY_PROBE_TIMEOUT_MS);
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await tunnel.fetch(path, { ...init, signal: controller.signal });
} finally {
@@ -423,13 +427,52 @@ const fetchRelayProbe = async (
}
};
/**
* Reach the host, letting the tunnel's own reconnect do the work.
*
* The tunnel rejects everything waiting on its channel the moment ONE connect
* attempt fails, even though it has already scheduled the next one with
* backoff. That is right for app traffic — `runtime-fetch` retries for itself —
* but it made a one-shot probe report a durable red "Unreachable" for a host
* that answers when the user presses refresh a second later. A cold start is
* exactly when that first attempt loses: DNS and TLS to the relay are cold, the
* remote host may still be re-establishing its control connection, and the
* probe competes with the app's own bootstrap traffic.
*
* A terminal tunnel state (auth failed, duplicate client, limit reached) will
* not resolve by waiting, so it ends the probe immediately.
*/
const fetchRelayProbeUntilDeadline = async (
tunnel: ReturnType<typeof createRelayTunnelClient>,
path: string,
deadline: number,
init?: RequestInit,
): Promise<Response> => {
for (;;) {
// Every attempt is capped by what is LEFT of the budget, not by the full
// per-request timeout: an attempt started just under the deadline would
// otherwise run the whole 8s past it, and the switch flow waits on this.
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) throw new Error('relay probe deadline exceeded');
try {
return await fetchRelayProbe(tunnel, path, Math.min(RELAY_PROBE_TIMEOUT_MS, remainingMs), init);
} catch (error) {
if (tunnel.getStatus().state === 'error') throw error;
if (Date.now() >= deadline) throw error;
await new Promise((resolve) => window.setTimeout(resolve, RELAY_PROBE_RETRY_DELAY_MS));
}
}
};
/**
* Reachability and client-auth check for a relay host: open a throwaway E2EE
* tunnel, verify `/health`, then verify `/auth/session` with the saved bearer.
* Relay hosts have no HTTP address for `desktopHostProbe`. Hard timeout: a
* ghost relay registration (relay lost the host, host doesn't know) leaves the
* tunnel in `connecting` forever — the probe must report unreachable instead
* of hanging every status/switch flow with it.
* Relay hosts have no HTTP address for `desktopHostProbe`. Bounded by
* `RELAY_PROBE_DEADLINE_MS`: a ghost relay registration (relay lost the host,
* host doesn't know) leaves the tunnel reconnecting forever — the probe must
* report unreachable rather than hang every status/switch flow with it — while
* still spanning enough reconnect attempts that a cold first attempt is not
* mistaken for an unreachable instance.
*/
export const probeRelayDesktopHost = async (
relay: DesktopHostRelay,
@@ -444,9 +487,10 @@ export const probeRelayDesktopHost = async (
hostEncPubJwk: relay.hostEncPubJwk,
});
const startedAt = Date.now();
const deadline = startedAt + RELAY_PROBE_DEADLINE_MS;
let keep = false;
try {
const response = await fetchRelayProbe(tunnel, '/health');
const response = await fetchRelayProbeUntilDeadline(tunnel, '/health', deadline);
if (!response.ok) return { status: 'unreachable', latencyMs: 0 };
const headers = new Headers({ Accept: 'application/json' });
for (const [name, value] of Object.entries(options?.requestHeaders || {})) {
@@ -454,7 +498,7 @@ export const probeRelayDesktopHost = async (
}
const clientToken = options?.clientToken?.trim();
if (clientToken) headers.set('Authorization', `Bearer ${clientToken}`);
const sessionResponse = await fetchRelayProbe(tunnel, '/auth/session', { headers });
const sessionResponse = await fetchRelayProbeUntilDeadline(tunnel, '/auth/session', deadline, { headers });
if (sessionResponse.status === 401 || sessionResponse.status === 403) {
return { status: 'auth', latencyMs: Math.max(0, Date.now() - startedAt) };
}
+28
View File
@@ -1,6 +1,34 @@
import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop';
type InvokeArgs = Record<string, unknown>;
type RelayDevTunnelData = ArrayBuffer | Uint8Array;
type RelayDevTunnelMessage = { type: 'connect' | 'ready' | 'data' | 'close'; data?: RelayDevTunnelData };
type RelayDevTunnelEvent = { connectionId: string; remotePort: number; message: RelayDevTunnelMessage };
type RelayDevTunnelBridge = {
relayDevTunnelListen?: (handler: (event: RelayDevTunnelEvent) => void) => void;
relayDevTunnelPost?: (connectionId: string, message: RelayDevTunnelMessage) => void;
};
declare global {
interface Window {
__OPENCHAMBER_DESKTOP__?: RelayDevTunnelBridge;
}
}
const getRelayDevTunnelBridge = (): RelayDevTunnelBridge | null => {
return globalThis.window?.__OPENCHAMBER_DESKTOP__ ?? null;
};
export const listenForDesktopRelayDevTunnels = (handler: (event: RelayDevTunnelEvent) => void): boolean => {
const bridge = getRelayDevTunnelBridge();
if (!bridge?.relayDevTunnelListen) return false;
bridge.relayDevTunnelListen(handler);
return true;
};
export const postDesktopRelayDevTunnelMessage = (connectionId: string, message: RelayDevTunnelMessage): void => {
getRelayDevTunnelBridge()?.relayDevTunnelPost?.(connectionId, message);
};
export const invokeDesktopCommand = async <TValue = unknown>(
command: string,
+4
View File
@@ -4,6 +4,10 @@ const loadedFaces = new Set<string>();
const pendingFaces = new Map<string, Promise<void>>();
const buildFontUrl = (source: FontFaceSource, weight: number) => {
if ('urls' in source) {
return source.urls[weight];
}
const packageName = encodeURIComponent(source.packageName).replace('%40', '@').replace('%2F', '/');
return `https://cdn.jsdelivr.net/npm/${packageName}/files/${source.filePrefix}-latin-${weight}-normal.woff2`;
};
+28 -4
View File
@@ -1,14 +1,23 @@
export type UiFontOption = 'inter' | 'geist-sans' | 'atkinson-hyperlegible' | 'source-sans-3' | 'roboto' | 'noto-sans' | 'dm-sans' | 'manrope' | 'system';
export type UiFontOption = 'inter' | 'fixel' | 'geist-sans' | 'atkinson-hyperlegible' | 'source-sans-3' | 'roboto' | 'noto-sans' | 'dm-sans' | 'manrope' | 'system';
export type MonoFontOption = 'jetbrains-mono' | 'fira-code' | 'geist-mono' | 'commit-mono' | 'source-code-pro' | 'cascadia-code' | 'roboto-mono' | 'iosevka' | 'system-mono';
export interface FontFaceSource {
interface FontFaceSourceBase {
family: string;
packageName: string;
filePrefix: string;
weights: number[];
}
interface FontsourceFaceSource extends FontFaceSourceBase {
packageName: string;
filePrefix: string;
}
interface DirectFontFaceSource extends FontFaceSourceBase {
urls: Record<number, string>;
}
export type FontFaceSource = FontsourceFaceSource | DirectFontFaceSource;
export interface FontOptionDefinition<T extends string> {
id: T;
label: string;
@@ -26,6 +35,21 @@ export const UI_FONT_OPTIONS: FontOptionDefinition<UiFontOption>[] = [
stack: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
source: { family: 'Inter', packageName: '@fontsource/inter', filePrefix: 'inter', weights: [400, 500, 600] }
},
{
id: 'fixel',
label: 'Fixel Text',
description: 'Humanist geometric sans-serif with full Ukrainian support.',
stack: '"Fixel Text", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
source: {
family: 'Fixel Text',
weights: [400, 500, 600],
urls: {
400: 'https://cdn.jsdelivr.net/gh/MacPaw/Fixel@f6ee910e98add47e830db87f1a754130506c11a2/fonts/webfonts/FixelText-Regular.woff2',
500: 'https://cdn.jsdelivr.net/gh/MacPaw/Fixel@f6ee910e98add47e830db87f1a754130506c11a2/fonts/webfonts/FixelText-Medium.woff2',
600: 'https://cdn.jsdelivr.net/gh/MacPaw/Fixel@f6ee910e98add47e830db87f1a754130506c11a2/fonts/webfonts/FixelText-SemiBold.woff2'
}
}
},
{
id: 'geist-sans',
label: 'Geist Sans',
+6
View File
@@ -214,6 +214,12 @@ export async function getGitBranches(directory: string): Promise<import('./api/t
return gitHttp.getGitBranches(directory);
}
export async function getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise<import('./api/types').GitUnpushedBranchCounts> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitUnpushedBranchCounts(directory, branches);
return gitHttp.getGitUnpushedBranchCounts(directory, branches);
}
export async function deleteGitBranch(directory: string, payload: import('./api/types').GitDeleteBranchPayload): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtimeStatusMutation(directory, runtime.deleteGitBranch(directory, payload));
+11
View File
@@ -7,6 +7,7 @@ import type {
GitFileDiffResponse,
GetGitFileDiffOptions,
GitBranch,
GitUnpushedBranchCounts,
GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload,
GitRemoveRemotePayload,
@@ -493,6 +494,16 @@ export async function getGitBranches(directory: string): Promise<GitBranch> {
return response.json();
}
export async function getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise<GitUnpushedBranchCounts> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/branch-push-status`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branches }),
});
if (!response.ok) throw new Error(`Failed to get branch push status: ${response.statusText}`);
return response.json();
}
export async function deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> {
if (!payload?.branch) {
throw new Error('branch is required to delete a branch');
@@ -2136,6 +2136,8 @@ export const settingsDict = {
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Warteschlange',
'settings.providers.page.quotaCredentials.accessToken': 'Zugriffstoken',
'settings.providers.page.quotaCredentials.usageToken': 'Nutzungs-API-Token',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Führen Sie diesen Befehl im Terminal aus und fügen Sie dann das Token unten ein. Es kann nur die LLM-Guthabennutzung lesen und läuft nach 30 Tagen ab.',
'settings.providers.page.quotaCredentials.refreshToken': 'Aktualisierungstoken',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token einfügen',
'settings.view.nav.group.general': 'OpenChamber',
+16
View File
@@ -699,6 +699,20 @@ export const dict = {
'gitView.commit.stageFilesHint': 'Stagen Sie Dateien, um Commit zu aktivieren.',
'gitView.commit.title': 'Commit',
'gitView.common.cancel': 'Abbrechen',
'gitView.branch.switchBlockedNotice': 'Nicht committete Änderungen — vor dem Wechsel folgt ein Commit-oder-Verwerfen-Schritt.',
'gitView.branch.unpushedSingle': '1 Commit nicht gepusht',
'gitView.branch.unpushedPlural': '{count} Commits nicht gepusht',
'gitView.branch.recentBranches': 'Kürzliche Branches',
'gitView.dirtySwitch.title': 'Nicht committete Änderungen',
'gitView.dirtySwitch.descriptionSingle': 'Der Wechsel zu {branch} ist angehalten, damit die geänderte Datei nicht verloren geht. Zuerst committen oder verwerfen.',
'gitView.dirtySwitch.descriptionPlural': 'Der Wechsel zu {branch} ist angehalten, damit die {count} geänderten Dateien nicht verloren gehen. Zuerst committen oder verwerfen.',
'gitView.dirtySwitch.commitAndSwitch': 'Committen und wechseln',
'gitView.dirtySwitch.committedNotPushed': 'Auf {branch} committet. Der Commit ist nur lokal — er wurde nicht gepusht.',
'gitView.dirtySwitch.pushAfterCommit': 'Nach dem Commit pushen',
'gitView.dirtySwitch.pushFailed': 'Committet, aber der Push ist fehlgeschlagen — der Branch wurde nicht gewechselt.',
'gitView.dirtySwitch.actionFailed': 'Die Aktion ist fehlgeschlagen; der Branch wurde nicht gewechselt.',
'gitView.dirtySwitch.revertAndSwitch': 'Verwerfen und wechseln',
'gitView.dirtySwitch.revertIncomplete': 'Einige Änderungen konnten nicht verworfen werden, der Branch wurde nicht gewechselt.',
'gitView.common.close': 'Schließen',
'gitView.common.done': 'Fertig',
'gitView.common.processing': 'Verarbeitung läuft...',
@@ -1429,6 +1443,8 @@ export const dict = {
'chat.autoReview.reviewSessionLabel': 'Überprüfungssitzung',
'chat.autoReview.actions.open': 'Öffnen',
'chat.autoReview.actions.stop': 'Stoppen',
'chat.draftDirtyNotice.tooltip': 'Dieser Branch hat nicht committete Dateien.\nDie neue Session sieht sie. Ein Commit oder ein Worktree hält sie getrennt.',
'chat.draftDirtyNotice.indicatorAria': 'Nicht committete Änderungen in diesem Verzeichnis',
'diffView.hunk.label': 'Stücke',
'diffView.hunk.stage': 'Zu Staging hinzufügen',
'diffView.hunk.unstage': 'Aus Staging entfernen',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Delete',
'settings.providers.page.quotaCredentials.saved': '{provider} credentials saved.',
'settings.providers.page.quotaCredentials.accessToken': 'Access token',
'settings.providers.page.quotaCredentials.usageToken': 'Usage API token',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Run this command in your terminal, then paste the token below. It can only read LLM credit usage and expires after 30 days.',
'settings.providers.page.quotaCredentials.refreshToken': 'Refresh token',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Paste token',
'settings.providers.page.openCodeGo.saveFailed': 'Could not validate OpenCode Go credentials.',
+16
View File
@@ -795,6 +795,20 @@ export const dict = {
'gitView.commit.stageFilesHint': 'Stage files to enable commit.',
'gitView.commit.title': 'Commit',
'gitView.common.cancel': 'Cancel',
'gitView.branch.switchBlockedNotice': 'Uncommitted changes — switching opens a commit-or-revert step first.',
'gitView.branch.unpushedSingle': '1 commit not pushed',
'gitView.branch.unpushedPlural': '{count} commits not pushed',
'gitView.branch.recentBranches': 'Recent branches',
'gitView.dirtySwitch.title': 'Uncommitted changes',
'gitView.dirtySwitch.descriptionSingle': 'Switching to {branch} is paused so your changed file is not lost. Commit it, or revert it first.',
'gitView.dirtySwitch.descriptionPlural': 'Switching to {branch} is paused so your {count} changed files are not lost. Commit them, or revert them first.',
'gitView.dirtySwitch.commitAndSwitch': 'Commit and switch',
'gitView.dirtySwitch.committedNotPushed': 'Committed to {branch}. The commit is local only — it has not been pushed.',
'gitView.dirtySwitch.pushAfterCommit': 'Push after commit',
'gitView.dirtySwitch.pushFailed': 'Committed, but the push failed — the branch was not switched.',
'gitView.dirtySwitch.actionFailed': 'The action failed; the branch was not switched.',
'gitView.dirtySwitch.revertAndSwitch': 'Revert and switch',
'gitView.dirtySwitch.revertIncomplete': 'Some changes could not be reverted, so the branch was not switched.',
'gitView.common.close': 'Close',
'gitView.common.done': 'Done',
'gitView.common.processing': 'Processing...',
@@ -1626,6 +1640,8 @@ export const dict = {
'chat.autoReview.reviewSessionLabel': 'Review session',
'chat.autoReview.actions.open': 'Open',
'chat.autoReview.actions.stop': 'Stop',
'chat.draftDirtyNotice.tooltip': 'This branch has uncommitted files.\nThe new session will see them. A commit or a worktree keeps them separate.',
'chat.draftDirtyNotice.indicatorAria': 'Uncommitted changes in this directory',
'diffView.hunk.label': 'Hunks',
'diffView.hunk.stage': 'Stage',
'diffView.hunk.unstage': 'Unstage',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Eliminar',
'settings.providers.page.quotaCredentials.saved': 'Credenciales de {provider} guardadas.',
'settings.providers.page.quotaCredentials.accessToken': 'Token de acceso',
'settings.providers.page.quotaCredentials.usageToken': 'Token de API de uso',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Ejecuta este comando en tu terminal y pega el token abajo. Solo puede leer el uso de créditos de LLM y caduca después de 30 días.',
'settings.providers.page.quotaCredentials.refreshToken': 'Token de actualización',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Pega el token',
'settings.providers.page.openCodeGo.saveFailed': 'No se pudieron validar las credenciales de OpenCode Go.',
+16
View File
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
"gitView.commit.stageFilesHint": "Prepara archivos para habilitar el commit.",
"gitView.commit.title": "Commit",
"gitView.common.cancel": "Cancelar",
'gitView.branch.switchBlockedNotice': 'Cambios sin confirmar: antes de cambiar de rama se ofrece confirmar o revertir.',
'gitView.branch.unpushedSingle': '1 commit sin push',
'gitView.branch.unpushedPlural': '{count} commits sin push',
'gitView.branch.recentBranches': 'Ramas recientes',
'gitView.dirtySwitch.title': 'Cambios sin confirmar',
'gitView.dirtySwitch.descriptionSingle': 'El cambio a {branch} está en pausa para no perder tu archivo modificado. Confírmalo o reviértelo primero.',
'gitView.dirtySwitch.descriptionPlural': 'El cambio a {branch} está en pausa para no perder tus {count} archivos modificados. Confírmalos o reviértelos primero.',
'gitView.dirtySwitch.commitAndSwitch': 'Confirmar y cambiar',
'gitView.dirtySwitch.committedNotPushed': 'Confirmado en {branch}. El commit es solo local: no se ha hecho push.',
'gitView.dirtySwitch.pushAfterCommit': 'Hacer push después del commit',
'gitView.dirtySwitch.pushFailed': 'Se confirmó, pero el push falló: no se cambió de rama.',
'gitView.dirtySwitch.actionFailed': 'La acción falló; no se cambió de rama.',
'gitView.dirtySwitch.revertAndSwitch': 'Revertir y cambiar',
'gitView.dirtySwitch.revertIncomplete': 'Algunos cambios no se pudieron revertir, así que no se cambió de rama.',
"gitView.common.close": "Cerrar",
"gitView.common.done": "Hecho",
"gitView.common.processing": "Procesando...",
@@ -1604,6 +1618,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': 'Sesión de revisión',
'chat.autoReview.actions.open': 'Abrir',
'chat.autoReview.actions.stop': 'Detener',
'chat.draftDirtyNotice.tooltip': 'Esta rama tiene archivos sin confirmar.\nLa nueva sesión los verá. Un commit o un worktree los mantiene separados.',
'chat.draftDirtyNotice.indicatorAria': 'Cambios sin confirmar en este directorio',
"diffView.hunk.label": "Fragmentos",
"diffView.hunk.stage": "Preparar",
"diffView.hunk.unstage": "Quitar",
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Supprimer',
'settings.providers.page.quotaCredentials.saved': 'Identifiants de {provider} enregistrés.',
'settings.providers.page.quotaCredentials.accessToken': 'Jeton daccès',
'settings.providers.page.quotaCredentials.usageToken': 'Jeton API dutilisation',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Exécutez cette commande dans votre terminal, puis collez le jeton ci-dessous. Il peut uniquement lire lutilisation des crédits LLM et expire après 30 jours.',
'settings.providers.page.quotaCredentials.refreshToken': 'Jeton dactualisation',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Collez le jeton',
'settings.providers.page.openCodeGo.saveFailed': 'Impossible de valider les identifiants OpenCode Go.',
+16
View File
@@ -618,6 +618,20 @@ export const dict = {
'gitView.commit.stageFilesHint': 'Ajoutez des fichiers à lindex pour activer le commit.',
'gitView.commit.title': 'Commettre',
'gitView.common.cancel': 'Annuler',
'gitView.branch.switchBlockedNotice': 'Modifications non commitées — le changement passe dabord par un commit ou une annulation.',
'gitView.branch.unpushedSingle': '1 commit non poussé',
'gitView.branch.unpushedPlural': '{count} commits non poussés',
'gitView.branch.recentBranches': 'Branches récentes',
'gitView.dirtySwitch.title': 'Modifications non commitées',
'gitView.dirtySwitch.descriptionSingle': 'Le passage à {branch} est suspendu pour ne pas perdre votre fichier modifié. Commitez-le ou annulez-le dabord.',
'gitView.dirtySwitch.descriptionPlural': 'Le passage à {branch} est suspendu pour ne pas perdre vos {count} fichiers modifiés. Commitez-les ou annulez-les dabord.',
'gitView.dirtySwitch.commitAndSwitch': 'Commiter et changer',
'gitView.dirtySwitch.committedNotPushed': 'Commité sur {branch}. Le commit est local uniquement — il na pas été poussé.',
'gitView.dirtySwitch.pushAfterCommit': 'Pousser après le commit',
'gitView.dirtySwitch.pushFailed': 'Commité, mais le push a échoué — la branche na pas été changée.',
'gitView.dirtySwitch.actionFailed': 'Laction a échoué ; la branche na pas été changée.',
'gitView.dirtySwitch.revertAndSwitch': 'Annuler et changer',
'gitView.dirtySwitch.revertIncomplete': 'Certaines modifications nont pas pu être annulées, la branche na donc pas été changée.',
'gitView.common.close': 'Fermer',
'gitView.common.done': 'Fait',
'gitView.common.processing': 'Traitement...',
@@ -1390,6 +1404,8 @@ export const dict = {
'chat.autoReview.reviewSessionLabel': 'Session de revue',
'chat.autoReview.actions.open': 'Ouvrir',
'chat.autoReview.actions.stop': 'Arrêter',
'chat.draftDirtyNotice.tooltip': 'Cette branche a des fichiers non commités.\nLa nouvelle session les verra. Un commit ou un worktree les garde séparés.',
'chat.draftDirtyNotice.indicatorAria': 'Modifications non commitées dans ce répertoire',
'diffView.hunk.label': 'Sections',
'diffView.hunk.stage': 'Préparer',
'diffView.hunk.unstage': 'Retirer',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': '削除',
'settings.providers.page.quotaCredentials.saved': '{provider} の認証情報を保存しました。',
'settings.providers.page.quotaCredentials.accessToken': 'アクセストークン',
'settings.providers.page.quotaCredentials.usageToken': '使用量 API トークン',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'このコマンドをターミナルで実行し、下にトークンを貼り付けてください。LLM クレジット使用量の読み取りのみが可能で、30 日後に期限切れになります。',
'settings.providers.page.quotaCredentials.refreshToken': '更新トークン',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'トークンを貼り付け',
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go の認証情報を検証できませんでした。',
+16
View File
@@ -793,6 +793,20 @@ export const dict: Record<I18nKey, string> = {
'gitView.commit.stageFilesHint': 'ファイルをステージするとコミットが有効になります。',
'gitView.commit.title': 'コミット',
'gitView.common.cancel': 'キャンセル',
'gitView.branch.switchBlockedNotice': '未コミットの変更があります — 切り替え前にコミットまたは破棄の手順が入ります。',
'gitView.branch.unpushedSingle': '未プッシュのコミットが1件',
'gitView.branch.unpushedPlural': '未プッシュのコミットが{count}件',
'gitView.branch.recentBranches': '最近のブランチ',
'gitView.dirtySwitch.title': '未コミットの変更',
'gitView.dirtySwitch.descriptionSingle': '変更したファイルを失わないよう、{branch}への切り替えを一時停止しました。先にコミットするか破棄してください。',
'gitView.dirtySwitch.descriptionPlural': '変更した{count}件のファイルを失わないよう、{branch}への切り替えを一時停止しました。先にコミットするか破棄してください。',
'gitView.dirtySwitch.commitAndSwitch': 'コミットして切り替え',
'gitView.dirtySwitch.committedNotPushed': '{branch}にコミットしました。このコミットはローカルのみで、プッシュされていません。',
'gitView.dirtySwitch.pushAfterCommit': 'コミット後にプッシュ',
'gitView.dirtySwitch.pushFailed': 'コミットしましたが、プッシュに失敗したためブランチは切り替えませんでした。',
'gitView.dirtySwitch.actionFailed': '操作に失敗したため、ブランチは切り替えませんでした。',
'gitView.dirtySwitch.revertAndSwitch': '破棄して切り替え',
'gitView.dirtySwitch.revertIncomplete': '一部の変更を破棄できなかったため、ブランチは切り替えませんでした。',
'gitView.common.close': '閉じる',
'gitView.common.done': '完了',
'gitView.common.processing': '処理中...',
@@ -1631,6 +1645,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': 'レビューセッション',
'chat.autoReview.actions.open': '開く',
'chat.autoReview.actions.stop': '停止',
'chat.draftDirtyNotice.tooltip': 'このブランチには未コミットのファイルがあります。\n新しいセッションからも見えます。コミットまたはワークツリーで分けられます。',
'chat.draftDirtyNotice.indicatorAria': 'このディレクトリに未コミットの変更があります',
'rightSidebar.contextNotesTodo.plan.defaultTitle': '計画',
'rightSidebar.contextNotesTodo.empty.selectProject': 'プロジェクトを選択してメモとTODOを追加します。',
'rightSidebar.contextNotesTodo.notes.placeholder': 'コンテキスト、リマインダー、リンクを記録',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': '삭제',
'settings.providers.page.quotaCredentials.saved': '{provider} 인증 정보를 저장했습니다.',
'settings.providers.page.quotaCredentials.accessToken': '액세스 토큰',
'settings.providers.page.quotaCredentials.usageToken': '사용량 API 토큰',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '터미널에서 이 명령을 실행한 다음 아래에 토큰을 붙여 넣으세요. LLM 크레딧 사용량만 읽을 수 있으며 30일 후 만료됩니다.',
'settings.providers.page.quotaCredentials.refreshToken': '새로 고침 토큰',
'settings.providers.page.quotaCredentials.tokenPlaceholder': '토큰 붙여넣기',
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go 인증 정보를 검증할 수 없습니다.',
+16
View File
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
'gitView.commit.stageFilesHint': '커밋하려면 파일을 스테이징하세요.',
'gitView.commit.title': '커밋',
'gitView.common.cancel': '취소',
'gitView.branch.switchBlockedNotice': '커밋되지 않은 변경 사항이 있습니다 — 전환 전에 커밋 또는 되돌리기 단계가 먼저 열립니다.',
'gitView.branch.unpushedSingle': '푸시되지 않은 커밋 1개',
'gitView.branch.unpushedPlural': '푸시되지 않은 커밋 {count}개',
'gitView.branch.recentBranches': '최근 브랜치',
'gitView.dirtySwitch.title': '커밋되지 않은 변경 사항',
'gitView.dirtySwitch.descriptionSingle': '변경된 파일을 잃지 않도록 {branch}(으)로의 전환을 잠시 멈췄습니다. 먼저 커밋하거나 되돌리세요.',
'gitView.dirtySwitch.descriptionPlural': '변경된 파일 {count}개를 잃지 않도록 {branch}(으)로의 전환을 잠시 멈췄습니다. 먼저 커밋하거나 되돌리세요.',
'gitView.dirtySwitch.commitAndSwitch': '커밋하고 전환',
'gitView.dirtySwitch.committedNotPushed': '{branch}에 커밋했습니다. 이 커밋은 로컬 전용이며 푸시되지 않았습니다.',
'gitView.dirtySwitch.pushAfterCommit': '커밋 후 푸시',
'gitView.dirtySwitch.pushFailed': '커밋했지만 푸시에 실패하여 브랜치를 전환하지 않았습니다.',
'gitView.dirtySwitch.actionFailed': '작업이 실패하여 브랜치를 전환하지 않았습니다.',
'gitView.dirtySwitch.revertAndSwitch': '되돌리고 전환',
'gitView.dirtySwitch.revertIncomplete': '일부 변경 사항을 되돌리지 못해 브랜치를 전환하지 않았습니다.',
'gitView.common.close': '닫기',
'gitView.common.done': '완료',
'gitView.common.processing': '처리 중…',
@@ -1628,6 +1642,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': '리뷰 세션',
'chat.autoReview.actions.open': '열기',
'chat.autoReview.actions.stop': '중지',
'chat.draftDirtyNotice.tooltip': '이 브랜치에는 커밋되지 않은 파일이 있습니다.\n새 세션에서도 보입니다. 커밋 또는 워크트리로 분리할 수 있습니다.',
'chat.draftDirtyNotice.indicatorAria': '이 디렉터리에 커밋되지 않은 변경 사항이 있습니다',
'diffView.hunk.label': '허크',
'diffView.hunk.stage': '스테이지',
'diffView.hunk.unstage': '스테이지 해제',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Usuń',
'settings.providers.page.quotaCredentials.saved': 'Dane uwierzytelniające {provider} zostały zapisane.',
'settings.providers.page.quotaCredentials.accessToken': 'Token dostępu',
'settings.providers.page.quotaCredentials.usageToken': 'Token API użycia',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Uruchom to polecenie w terminalu, a następnie wklej token poniżej. Może on tylko odczytywać użycie środków LLM i wygasa po 30 dniach.',
'settings.providers.page.quotaCredentials.refreshToken': 'Token odświeżania',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Wklej token',
'settings.providers.page.openCodeGo.saveFailed': 'Nie udało się sprawdzić danych OpenCode Go.',
+16
View File
@@ -1844,6 +1844,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': 'Sesja review',
'chat.autoReview.actions.open': 'Otwórz',
'chat.autoReview.actions.stop': 'Zatrzymaj',
'chat.draftDirtyNotice.tooltip': 'Ta gałąź ma niezacommitowane pliki.\nNowa sesja będzie je widzieć. Commit albo worktree trzyma je osobno.',
'chat.draftDirtyNotice.indicatorAria': 'Niezacommitowane zmiany w tym katalogu',
'diffView.hunk.label': 'Fragmenty',
'diffView.hunk.stage': 'Przygotuj',
'diffView.hunk.unstage': 'Cofnij',
@@ -2106,6 +2108,20 @@ export const dict: Record<I18nKey, string> = {
'gitView.commit.stageFilesHint': 'Dodaj pliki do indeksu, aby włączyć commit.',
'gitView.commit.title': 'Commit',
'gitView.common.cancel': 'Anuluj',
'gitView.branch.switchBlockedNotice': 'Niezacommitowane zmiany — przed przełączeniem pojawi się krok commit lub cofnięcie.',
'gitView.branch.unpushedSingle': '1 niewypchnięty commit',
'gitView.branch.unpushedPlural': 'Niewypchnięte commity: {count}',
'gitView.branch.recentBranches': 'Ostatnie gałęzie',
'gitView.dirtySwitch.title': 'Niezacommitowane zmiany',
'gitView.dirtySwitch.descriptionSingle': 'Przełączenie na {branch} wstrzymano, aby nie stracić zmienionego pliku. Najpierw go zacommituj lub cofnij.',
'gitView.dirtySwitch.descriptionPlural': 'Przełączenie na {branch} wstrzymano, aby nie stracić {count} zmienionych plików. Najpierw je zacommituj lub cofnij.',
'gitView.dirtySwitch.commitAndSwitch': 'Zacommituj i przełącz',
'gitView.dirtySwitch.committedNotPushed': 'Zacommitowano na {branch}. Commit jest tylko lokalny — nie został wypchnięty.',
'gitView.dirtySwitch.pushAfterCommit': 'Wypchnij po commicie',
'gitView.dirtySwitch.pushFailed': 'Zacommitowano, ale push się nie powiódł — gałąź nie została przełączona.',
'gitView.dirtySwitch.actionFailed': 'Akcja nie powiodła się; gałąź nie została przełączona.',
'gitView.dirtySwitch.revertAndSwitch': 'Cofnij i przełącz',
'gitView.dirtySwitch.revertIncomplete': 'Nie udało się cofnąć części zmian, więc gałąź nie została przełączona.',
'gitView.common.close': 'Zamknij',
'gitView.common.done': 'Gotowe',
'gitView.common.processing': 'Przetwarzanie...',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Excluir',
'settings.providers.page.quotaCredentials.saved': 'Credenciais de {provider} salvas.',
'settings.providers.page.quotaCredentials.accessToken': 'Token de acesso',
'settings.providers.page.quotaCredentials.usageToken': 'Token da API de uso',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Execute este comando no terminal e cole o token abaixo. Ele só pode ler o uso de créditos de LLM e expira após 30 dias.',
'settings.providers.page.quotaCredentials.refreshToken': 'Token de atualização',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Cole o token',
'settings.providers.page.openCodeGo.saveFailed': 'Não foi possível validar as credenciais do OpenCode Go.',
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
"gitView.commit.stageFilesHint": "Adicione arquivos ao stage para habilitar o commit.",
"gitView.commit.title": "Commit",
"gitView.common.cancel": "Cancelar",
'gitView.branch.switchBlockedNotice': 'Alterações sem commit — antes de trocar, será oferecido commit ou reversão.',
'gitView.branch.unpushedSingle': '1 commit sem push',
'gitView.branch.unpushedPlural': '{count} commits sem push',
'gitView.branch.recentBranches': 'Branches recentes',
'gitView.dirtySwitch.title': 'Alterações sem commit',
'gitView.dirtySwitch.descriptionSingle': 'A troca para {branch} foi pausada para não perder seu arquivo alterado. Faça commit ou reverta primeiro.',
'gitView.dirtySwitch.descriptionPlural': 'A troca para {branch} foi pausada para não perder seus {count} arquivos alterados. Faça commit ou reverta primeiro.',
'gitView.dirtySwitch.commitAndSwitch': 'Fazer commit e trocar',
'gitView.dirtySwitch.committedNotPushed': 'Commit feito em {branch}. O commit é apenas local — não foi enviado com push.',
'gitView.dirtySwitch.pushAfterCommit': 'Fazer push após o commit',
'gitView.dirtySwitch.pushFailed': 'Commit feito, mas o push falhou — a branch não foi trocada.',
'gitView.dirtySwitch.actionFailed': 'A ação falhou; a branch não foi trocada.',
'gitView.dirtySwitch.revertAndSwitch': 'Reverter e trocar',
'gitView.dirtySwitch.revertIncomplete': 'Algumas alterações não puderam ser revertidas, então a branch não foi trocada.',
"gitView.common.close": "Fechar",
"gitView.common.done": "Concluído",
"gitView.common.processing": "Procesando...",
@@ -1604,6 +1618,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': 'Sessão de revisão',
'chat.autoReview.actions.open': 'Abrir',
'chat.autoReview.actions.stop': 'Parar',
'chat.draftDirtyNotice.tooltip': 'Esta branch tem arquivos sem commit.\nA nova sessão os verá. Um commit ou um worktree os mantém separados.',
'chat.draftDirtyNotice.indicatorAria': 'Alterações sem commit neste diretório',
"diffView.hunk.label": "Trechos",
"diffView.hunk.stage": "Preparar",
"diffView.hunk.unstage": "Remover",
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Sil',
'settings.providers.page.quotaCredentials.saved': '{provider} kimlik bilgileri kaydedildi.',
'settings.providers.page.quotaCredentials.accessToken': 'Erişim token\'ı',
'settings.providers.page.quotaCredentials.usageToken': 'Kullanım API token\'ı',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Bu komutu terminalde çalıştırın, ardından token\'ı aşağıya yapıştırın. Yalnızca LLM kredi kullanımını okuyabilir ve 30 gün sonra sona erer.',
'settings.providers.page.quotaCredentials.refreshToken': 'Yenileme token\'ı',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token\'ı yapıştır',
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go kimlik bilgileri doğrulanamadı.',
+20 -4
View File
@@ -777,6 +777,20 @@ export const dict = {
'gitView.commit.stageFilesHint': 'Commit\'i etkinleştirmek için dosyaları stage edin.',
'gitView.commit.title': 'Commit',
'gitView.common.cancel': 'İptal',
'gitView.branch.switchBlockedNotice': 'Commit edilmemiş değişiklikler var — geçişten önce commit veya geri alma adımı açılır.',
'gitView.branch.unpushedSingle': '1 commit push edilmedi',
'gitView.branch.unpushedPlural': '{count} commit push edilmedi',
'gitView.branch.recentBranches': 'Son kullanılan dallar',
'gitView.dirtySwitch.title': 'Commit edilmemiş değişiklikler',
'gitView.dirtySwitch.descriptionSingle': 'Değiştirilen dosyanız kaybolmasın diye {branch} dalına geçiş duraklatıldı. Önce commit edin veya geri alın.',
'gitView.dirtySwitch.descriptionPlural': 'Değiştirilen {count} dosyanız kaybolmasın diye {branch} dalına geçiş duraklatıldı. Önce commit edin veya geri alın.',
'gitView.dirtySwitch.commitAndSwitch': 'Commit et ve geç',
'gitView.dirtySwitch.committedNotPushed': '{branch} dalına commit edildi. Commit yalnızca yerel — push edilmedi.',
'gitView.dirtySwitch.pushAfterCommit': 'Commit sonrası push et',
'gitView.dirtySwitch.pushFailed': 'Commit edildi ancak push başarısız oldu — dal değiştirilmedi.',
'gitView.dirtySwitch.actionFailed': 'İşlem başarısız oldu; dal değiştirilmedi.',
'gitView.dirtySwitch.revertAndSwitch': 'Geri al ve geç',
'gitView.dirtySwitch.revertIncomplete': 'Bazı değişiklikler geri alınamadığı için dal değiştirilmedi.',
'gitView.common.close': 'Kapat',
'gitView.common.done': 'Tamam',
'gitView.common.processing': 'İşleniyor...',
@@ -796,10 +810,8 @@ export const dict = {
'gitView.conflict.resolveNewSession': 'Yeni session\'da çöz',
'gitView.empty.cleanDescription': 'Tüm değişiklikler commit edildi',
'gitView.empty.cleanTitle': 'Working tree temiz',
'gitView.empty.discoveringRepositories': 'Git repository\'leri aranıyor...',
'gitView.empty.discoverFailed': 'Git repository\'leri taranamadı',
'gitView.empty.retryDiscovery': 'Tekrar dene',
'gitView.empty.selectRepositoryPlaceholder': 'Bir repository seçin...',
'gitView.empty.discoveringRepositories': 'Git depoları aranıyor...',
'gitView.empty.discoverFailed': 'Git depoları taranamadı',
'gitView.empty.pullBehindPlural': '{count} commit pull et',
'gitView.empty.pullBehindSingle': '{count} commit pull et',
'gitView.header.identityTooltip': 'Git kimliği',
@@ -963,6 +975,8 @@ export const dict = {
'gitView.conflict.noDetailsAvailable': 'Çakışma detayları mevcut değil',
'gitView.empty.notGitRepository': 'Bu dizin bir Git repository\'si değil',
'gitView.empty.notGitRepositoryDescription': 'Bu dizinde Git\'i başlatın veya bir repository açın.',
'gitView.empty.retryDiscovery': 'Yeniden dene',
'gitView.empty.selectRepositoryPlaceholder': 'Bir repository seç...',
'gitView.empty.selectSessionOrDirectory': 'Git durumunu görüntülemek için bir session veya dizin seçin',
'gitView.empty.worktreeFeaturesUnavailable': 'Bu çalışma alanı modunda worktree özellikleri kullanılamıyor.',
'gitView.empty.worktreeSetupDescription': 'Worktree kurulumu tamamlanıyor ve repository durumu hazırlanıyor.',
@@ -1588,6 +1602,8 @@ export const dict = {
'chat.autoReview.reviewSessionLabel': 'İnceleme session\'ı',
'chat.autoReview.actions.open': 'Aç',
'chat.autoReview.actions.stop': 'Durdur',
'chat.draftDirtyNotice.tooltip': 'Bu dalda commit edilmemiş dosyalar var.\nYeni oturum onları görecek. Bir commit veya worktree onları ayrı tutar.',
'chat.draftDirtyNotice.indicatorAria': 'Bu dizinde commit edilmemiş değişiklikler var',
'diffView.hunk.label': 'Hunk\'lar',
'diffView.hunk.stage': 'Stage',
'diffView.hunk.unstage': 'Unstage',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': 'Видалити',
'settings.providers.page.quotaCredentials.saved': 'Облікові дані {provider} збережено.',
'settings.providers.page.quotaCredentials.accessToken': 'Токен доступу',
'settings.providers.page.quotaCredentials.usageToken': 'Токен API використання',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Виконайте цю команду в терміналі, а потім вставте токен нижче. Він може лише читати використання LLM-кредитів і діє 30 днів.',
'settings.providers.page.quotaCredentials.refreshToken': 'Токен оновлення',
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Вставте токен',
'settings.providers.page.openCodeGo.saveFailed': 'Не вдалося перевірити дані OpenCode Go.',
+16
View File
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
"gitView.commit.stageFilesHint": "Додайте файли до індексу, щоб увімкнути коміт.",
"gitView.commit.title": "Коміт",
"gitView.common.cancel": "Скасувати",
'gitView.branch.switchBlockedNotice': 'Є незакомічені зміни — перед перемиканням спершу буде крок «закомітити або скасувати».',
'gitView.branch.unpushedSingle': '1 незапушений коміт',
'gitView.branch.unpushedPlural': 'Незапушені коміти: {count}',
'gitView.branch.recentBranches': 'Нещодавні гілки',
'gitView.dirtySwitch.title': 'Незакомічені зміни',
'gitView.dirtySwitch.descriptionSingle': 'Перемикання на {branch} призупинено, щоб не втратити змінений файл. Спершу закоміть його або скасуй зміни.',
'gitView.dirtySwitch.descriptionPlural': 'Перемикання на {branch} призупинено, щоб не втратити {count} змінених файлів. Спершу закоміть їх або скасуй зміни.',
'gitView.dirtySwitch.commitAndSwitch': 'Закомітити й перемкнути',
'gitView.dirtySwitch.committedNotPushed': 'Закомічено в {branch}. Коміт лише локальний — його не запушено.',
'gitView.dirtySwitch.pushAfterCommit': 'Запушити після коміту',
'gitView.dirtySwitch.pushFailed': 'Закомічено, але push не вдався — гілку не перемкнено.',
'gitView.dirtySwitch.actionFailed': 'Дія не вдалася; гілку не перемкнено.',
'gitView.dirtySwitch.revertAndSwitch': 'Скасувати зміни й перемкнути',
'gitView.dirtySwitch.revertIncomplete': 'Частину змін не вдалося скасувати, тому гілку не перемкнено.',
"gitView.common.close": "Закрити",
"gitView.common.done": "Готово",
"gitView.common.processing": "Обробка...",
@@ -1604,6 +1618,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': 'Сесія ревʼю',
'chat.autoReview.actions.open': 'Відкрити',
'chat.autoReview.actions.stop': 'Зупинити',
'chat.draftDirtyNotice.tooltip': 'У цій гілці є незакомічені файли.\nНова сесія бачитиме їх. Коміт або worktree тримають їх окремо.',
'chat.draftDirtyNotice.indicatorAria': 'Незакомічені зміни в цьому каталозі',
"diffView.hunk.label": "Шматки",
"diffView.hunk.stage": "Додати",
"diffView.hunk.unstage": "Прибрати",
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': '删除',
'settings.providers.page.quotaCredentials.saved': '已保存 {provider} 凭据。',
'settings.providers.page.quotaCredentials.accessToken': '访问令牌',
'settings.providers.page.quotaCredentials.usageToken': '用量 API 令牌',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '在终端中运行此命令,然后在下方粘贴令牌。该令牌只能读取 LLM 积分用量,并将在 30 天后过期。',
'settings.providers.page.quotaCredentials.refreshToken': '刷新令牌',
'settings.providers.page.quotaCredentials.tokenPlaceholder': '粘贴令牌',
'settings.providers.page.openCodeGo.saveFailed': '无法验证 OpenCode Go 凭据。',
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
'gitView.commit.stageFilesHint': '暂存文件以启用提交。',
'gitView.commit.title': '提交',
'gitView.common.cancel': '取消',
'gitView.branch.switchBlockedNotice': '有未提交的更改 — 切换前会先进入提交或还原步骤。',
'gitView.branch.unpushedSingle': '1 个未推送的提交',
'gitView.branch.unpushedPlural': '{count} 个未推送的提交',
'gitView.branch.recentBranches': '最近分支',
'gitView.dirtySwitch.title': '未提交的更改',
'gitView.dirtySwitch.descriptionSingle': '为避免丢失已更改的文件,切换到 {branch} 已暂停。请先提交或还原。',
'gitView.dirtySwitch.descriptionPlural': '为避免丢失 {count} 个已更改的文件,切换到 {branch} 已暂停。请先提交或还原。',
'gitView.dirtySwitch.commitAndSwitch': '提交并切换',
'gitView.dirtySwitch.committedNotPushed': '已提交到 {branch}。该提交仅在本地,尚未推送。',
'gitView.dirtySwitch.pushAfterCommit': '提交后推送',
'gitView.dirtySwitch.pushFailed': '已提交,但推送失败 — 未切换分支。',
'gitView.dirtySwitch.actionFailed': '操作失败,未切换分支。',
'gitView.dirtySwitch.revertAndSwitch': '还原并切换',
'gitView.dirtySwitch.revertIncomplete': '部分更改无法还原,因此未切换分支。',
'gitView.common.close': '关闭',
'gitView.common.done': '完成',
'gitView.common.processing': '处理中...',
@@ -1592,6 +1606,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': '审查会话',
'chat.autoReview.actions.open': '打开',
'chat.autoReview.actions.stop': '停止',
'chat.draftDirtyNotice.tooltip': '此分支有未提交的文件。\n新会话会看到它们。提交或工作树可将它们分开。',
'chat.draftDirtyNotice.indicatorAria': '此目录有未提交的更改',
'diffView.hunk.label': '代码块',
'diffView.hunk.stage': '暂存',
'diffView.hunk.unstage': '取消暂存',
@@ -13,6 +13,8 @@ export const settingsDict = {
'settings.providers.page.openCodeGo.delete': '刪除',
'settings.providers.page.quotaCredentials.saved': '已儲存 {provider} 憑證。',
'settings.providers.page.quotaCredentials.accessToken': '存取權杖',
'settings.providers.page.quotaCredentials.usageToken': '用量 API 權杖',
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '在終端機中執行此命令,然後在下方貼上權杖。該權杖只能讀取 LLM 點數用量,並將在 30 天後到期。',
'settings.providers.page.quotaCredentials.refreshToken': '重新整理權杖',
'settings.providers.page.quotaCredentials.tokenPlaceholder': '貼上權杖',
'settings.providers.page.openCodeGo.saveFailed': '無法驗證 OpenCode Go 憑證。',
@@ -809,6 +809,20 @@ export const dict: Record<I18nKey, string> = {
'gitView.commit.stageFilesHint': '暫存文件以啟用提交。',
'gitView.commit.title': '提交',
'gitView.common.cancel': '取消',
'gitView.branch.switchBlockedNotice': '有未提交的變更 — 切換前會先進入提交或還原步驟。',
'gitView.branch.unpushedSingle': '1 個未推送的提交',
'gitView.branch.unpushedPlural': '{count} 個未推送的提交',
'gitView.branch.recentBranches': '最近分支',
'gitView.dirtySwitch.title': '未提交的變更',
'gitView.dirtySwitch.descriptionSingle': '為避免遺失已變更的檔案,切換到 {branch} 已暫停。請先提交或還原。',
'gitView.dirtySwitch.descriptionPlural': '為避免遺失 {count} 個已變更的檔案,切換到 {branch} 已暫停。請先提交或還原。',
'gitView.dirtySwitch.commitAndSwitch': '提交並切換',
'gitView.dirtySwitch.committedNotPushed': '已提交到 {branch}。該提交僅在本地,尚未推送。',
'gitView.dirtySwitch.pushAfterCommit': '提交後推送',
'gitView.dirtySwitch.pushFailed': '已提交,但推送失敗 — 未切換分支。',
'gitView.dirtySwitch.actionFailed': '操作失敗,未切換分支。',
'gitView.dirtySwitch.revertAndSwitch': '還原並切換',
'gitView.dirtySwitch.revertIncomplete': '部分變更無法還原,因此未切換分支。',
'gitView.common.close': '關閉',
'gitView.common.done': '完成',
'gitView.common.processing': '處理中...',
@@ -1602,6 +1616,8 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.reviewSessionLabel': '審查工作階段',
'chat.autoReview.actions.open': '開啟',
'chat.autoReview.actions.stop': '停止',
'chat.draftDirtyNotice.tooltip': '此分支有未提交的檔案。\n新的工作階段會看到它們。提交或工作樹可將它們分開。',
'chat.draftDirtyNotice.indicatorAria': '此目錄有未提交的變更',
'diffView.hunk.label': '程式碼區塊',
'diffView.hunk.stage': '暫存',
'diffView.hunk.unstage': '取消暫存',
+31
View File
@@ -875,6 +875,7 @@ describe('updateDesktopSettings', () => {
expect(synced.length).toBeGreaterThan(0);
const bootstrapSync = synced.find((detail) => detail.bootstrap);
expect(bootstrapSync).toBeTruthy();
expect(bootstrapSync?.adoptTheme).toBe(true);
expect(bootstrapSync?.settings.useSystemTheme).toBe(undefined);
expect(bootstrapSync?.settings.lightThemeId).toBe(undefined);
expect(bootstrapSync?.settings.darkThemeId).toBe(undefined);
@@ -905,8 +906,38 @@ describe('updateDesktopSettings', () => {
expect(synced.length).toBeGreaterThan(0);
expect(synced.every((detail) => detail.bootstrap === false)).toBe(true);
expect(synced.every((detail) => detail.adoptTheme === false)).toBe(true);
expect(synced.every((detail) => detail.settings.themeVariant === 'dark')).toBe(true);
});
test('allows a bootstrap sync to preserve the current window theme', async () => {
getWindow();
invalidateSettingsCache();
registerSettingsApi(
async (changes) => ({ ...changes } as SettingsPayload),
async () => ({
settings: { activeProjectId: 'project-a', themeVariant: 'dark' },
source: 'web',
}),
);
const synced: SettingsSyncedDetail[] = [];
const listener = (event: Event): void => {
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail;
if (detail) synced.push(detail);
};
window.addEventListener('openchamber:settings-synced', listener);
try {
await syncDesktopSettings({ adoptTheme: false });
} finally {
window.removeEventListener('openchamber:settings-synced', listener);
}
const broadcastSync = synced.find((detail) => detail.bootstrap && !detail.adoptTheme);
expect(broadcastSync).toBeTruthy();
expect(broadcastSync?.settings.activeProjectId).toBe('project-a');
expect(broadcastSync?.settings.themeVariant).toBe('dark');
});
});
describe('unload lifecycle flush (#2197)', () => {
+9 -4
View File
@@ -205,14 +205,18 @@ export interface SettingsSyncedDetail {
not filtered; listeners gate their adoption on this flag and keep their
live state for the fields they own. */
bootstrap: boolean;
/** Whether this sync may replace this window's theme preferences. VS Code
settings broadcasts remain bootstrap-grade for shared workspace pointers,
but must not copy one webview's theme into another webview. */
adoptTheme: boolean;
}
const dispatchSettingsSynced = (settings: DesktopSettings, bootstrap: boolean): void => {
const dispatchSettingsSynced = (settings: DesktopSettings, bootstrap: boolean, adoptTheme = bootstrap): void => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent<SettingsSyncedDetail>('openchamber:settings-synced', {
detail: { settings, bootstrap },
detail: { settings, bootstrap, adoptTheme },
}));
};
@@ -1900,8 +1904,9 @@ export const invalidateSettingsCache = (): void => {
_settingsCache = null;
};
export const syncDesktopSettings = async (options?: { bootstrap?: boolean }): Promise<void> => {
export const syncDesktopSettings = async (options?: { bootstrap?: boolean; adoptTheme?: boolean }): Promise<void> => {
const bootstrap = options?.bootstrap !== false;
const adoptTheme = options?.adoptTheme ?? bootstrap;
if (typeof window === 'undefined') {
return;
}
@@ -2030,7 +2035,7 @@ export const syncDesktopSettings = async (options?: { bootstrap?: boolean }): Pr
if (!isSettingsRuntimeContextCurrent(context)) return;
}
dispatchSettingsSynced(authoritativeSettings, bootstrap);
dispatchSettingsSynced(authoritativeSettings, bootstrap, adoptTheme);
};
try {
@@ -23,6 +23,7 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
{ id: 'opencode-go', name: 'OpenCode Go' },
{ id: 'crof', name: 'CrofAI' },
{ id: 'deepseek', name: 'DeepSeek' },
{ id: 'exe-dev', name: 'exe.dev' },
{ id: 'neuralwatt', name: 'NeuralWatt' },
{ id: 'xai', name: 'xAI' },
];
+1
View File
@@ -40,6 +40,7 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin
body: JSON.stringify({
prompt: trimmed,
system: NOTES_SYSTEM_PROMPT,
sessionID: sessionId || undefined,
restrictToPreferredProvider: true,
...(preferredProviderID ? { preferredProviderID } : {}),
...(preferredModelID ? { preferredModelID } : {}),