fix(ui): preserve VS Code themes during settings broadcasts
This commit is contained in:
@@ -155,6 +155,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
|
|||||||
- SSH host import, connections, logs, and port forwarding.
|
- SSH host import, connections, logs, and port forwarding.
|
||||||
- SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably.
|
- SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably.
|
||||||
- Tunnel lifecycle integration through the web server runtime.
|
- Tunnel lifecycle integration through the web server runtime.
|
||||||
|
- Remote dev-server previews use a direct WebSocket tunnel when the instance has an HTTP address. Relay-only instances keep the encrypted relay transport in the renderer and bridge its raw bytes to the browser panel through a local Electron listener.
|
||||||
- Auto-update checks, downloads, and restart/apply flow.
|
- Auto-update checks, downloads, and restart/apply flow.
|
||||||
- The browser panel's own session (`persist:openchamber-browser`): its storage is
|
- The browser panel's own session (`persist:openchamber-browser`): its storage is
|
||||||
cleared only through the scoped clear-data command, and camera, microphone,
|
cleared only through the scoped clear-data command, and camera, microphone,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron';
|
import { app, BrowserWindow, dialog, ipcMain, Menu, MessageChannelMain, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron';
|
||||||
import contextMenu from 'electron-context-menu';
|
import contextMenu from 'electron-context-menu';
|
||||||
import log from 'electron-log/main.js';
|
import log from 'electron-log/main.js';
|
||||||
import dgram from 'node:dgram';
|
import dgram from 'node:dgram';
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from './linux-autostart.mjs';
|
} from './linux-autostart.mjs';
|
||||||
import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs';
|
import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs';
|
||||||
import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs';
|
import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs';
|
||||||
|
import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs';
|
||||||
import { attachRendererRecovery } from './renderer-recovery.mjs';
|
import { attachRendererRecovery } from './renderer-recovery.mjs';
|
||||||
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
|
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
|
||||||
|
|
||||||
@@ -3839,6 +3840,7 @@ const runSpecChain = (specs, appName) => {
|
|||||||
// The tunnel client lives in the web package (it already has a WebSocket
|
// The tunnel client lives in the web package (it already has a WebSocket
|
||||||
// client) and is loaded only if the user actually previews a remote dev server.
|
// client) and is loaded only if the user actually previews a remote dev server.
|
||||||
let devTunnelClientPromise = null;
|
let devTunnelClientPromise = null;
|
||||||
|
const relayDevTunnelBridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannelMain(), logger: log });
|
||||||
const getDevTunnelClient = async () => {
|
const getDevTunnelClient = async () => {
|
||||||
if (!devTunnelClientPromise) {
|
if (!devTunnelClientPromise) {
|
||||||
devTunnelClientPromise = import('@openchamber/web/server/lib/dev-tunnel/client.js')
|
devTunnelClientPromise = import('@openchamber/web/server/lib/dev-tunnel/client.js')
|
||||||
@@ -3852,6 +3854,7 @@ const getDevTunnelClient = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeAllDevTunnels = () => {
|
const closeAllDevTunnels = () => {
|
||||||
|
relayDevTunnelBridge.closeAll();
|
||||||
if (!devTunnelClientPromise) return;
|
if (!devTunnelClientPromise) return;
|
||||||
const pending = devTunnelClientPromise;
|
const pending = devTunnelClientPromise;
|
||||||
devTunnelClientPromise = null;
|
devTunnelClientPromise = null;
|
||||||
@@ -3959,6 +3962,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
|||||||
if (!baseUrl) throw new Error('baseUrl is required');
|
if (!baseUrl) throw new Error('baseUrl is required');
|
||||||
if (!(port > 0 && port <= 65535)) throw new Error('A valid port is required');
|
if (!(port > 0 && port <= 65535)) throw new Error('A valid port is required');
|
||||||
|
|
||||||
|
if (args.relay === true) {
|
||||||
|
const targetKey = typeof args.targetKey === 'string' ? args.targetKey.trim() : '';
|
||||||
|
return relayDevTunnelBridge.open({ targetKey, remotePort: port, webContents: browserWindow?.webContents });
|
||||||
|
}
|
||||||
|
|
||||||
const headers = {};
|
const headers = {};
|
||||||
const requestHeaders = args.requestHeaders && typeof args.requestHeaders === 'object' ? args.requestHeaders : {};
|
const requestHeaders = args.requestHeaders && typeof args.requestHeaders === 'object' ? args.requestHeaders : {};
|
||||||
for (const [name, value] of Object.entries(requestHeaders)) {
|
for (const [name, value] of Object.entries(requestHeaders)) {
|
||||||
@@ -3981,6 +3989,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
|||||||
return { closed: client.close({ baseUrl, port }) };
|
return { closed: client.close({ baseUrl, port }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'desktop_relay_dev_tunnel_close_all':
|
||||||
|
return { closed: relayDevTunnelBridge.closeForWebContents(browserWindow?.webContents.id) };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forces prefers-color-scheme for one previewed page.
|
* Forces prefers-color-scheme for one previewed page.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -158,14 +158,41 @@ ipcRenderer.on('openchamber:emit', (_evt, payload) => {
|
|||||||
dispatchNativeEvent(event, payload.detail);
|
dispatchNativeEvent(event, payload.detail);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const relayDevTunnelPorts = new Map();
|
||||||
|
let relayDevTunnelHandler = null;
|
||||||
|
ipcRenderer.on('openchamber:relay-dev-tunnel-connect', (event, payload) => {
|
||||||
|
if (!isLocalPage || !payload || typeof payload.connectionId !== 'string' || !event.ports?.[0]) return;
|
||||||
|
const port = event.ports[0];
|
||||||
|
relayDevTunnelPorts.set(payload.connectionId, port);
|
||||||
|
port.onmessage = (messageEvent) => relayDevTunnelHandler?.({
|
||||||
|
connectionId: payload.connectionId,
|
||||||
|
remotePort: payload.remotePort,
|
||||||
|
message: messageEvent.data,
|
||||||
|
});
|
||||||
|
port.start();
|
||||||
|
relayDevTunnelHandler?.({ connectionId: payload.connectionId, remotePort: payload.remotePort, message: { type: 'connect' } });
|
||||||
|
});
|
||||||
|
|
||||||
// The desktop bridge is exposed on all pages; the main-process gate in
|
// The desktop bridge is exposed on all pages; the main-process gate in
|
||||||
// ipcMain.handle('openchamber:invoke') decides per-command what is safe
|
// ipcMain.handle('openchamber:invoke') decides per-command what is safe
|
||||||
// for non-local callers (window/host-switcher ops yes, file/shell ops
|
// for non-local callers (window/host-switcher ops yes, file/shell ops
|
||||||
// no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs.
|
// no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs.
|
||||||
contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', {
|
const desktopBridge = {
|
||||||
invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}),
|
invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}),
|
||||||
openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}),
|
openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}),
|
||||||
grantFileAccess: (filePath) => ipcRenderer.invoke('openchamber:file:grant-existing', filePath),
|
grantFileAccess: (filePath) => ipcRenderer.invoke('openchamber:file:grant-existing', filePath),
|
||||||
openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }),
|
openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }),
|
||||||
listen: async (event, handler) => addListener(event, handler),
|
listen: async (event, handler) => addListener(event, handler),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (isLocalPage) {
|
||||||
|
desktopBridge.relayDevTunnelListen = (handler) => {
|
||||||
|
relayDevTunnelHandler = typeof handler === 'function' ? handler : null;
|
||||||
|
};
|
||||||
|
desktopBridge.relayDevTunnelPost = (connectionId, message) => {
|
||||||
|
relayDevTunnelPorts.get(connectionId)?.postMessage(message);
|
||||||
|
if (message?.type === 'close') relayDevTunnelPorts.delete(connectionId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', desktopBridge);
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import net from 'node:net';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const CONNECTION_READY_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
const listen = (server) => new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
server.off('error', reject);
|
||||||
|
const address = server.address();
|
||||||
|
const port = Number(address?.port);
|
||||||
|
if (!Number.isInteger(port) || port <= 0) {
|
||||||
|
reject(new Error('Failed to bind a local relay tunnel port'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageData = (event) => {
|
||||||
|
if (event?.type === 'ready' || event?.type === 'data' || event?.type === 'close') return event;
|
||||||
|
return event?.data ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRelayDevTunnelBridge = ({ createMessageChannel, logger = console } = {}) => {
|
||||||
|
const tunnels = new Map();
|
||||||
|
|
||||||
|
const closeTunnel = (key) => {
|
||||||
|
const tunnel = tunnels.get(key);
|
||||||
|
if (!tunnel) return false;
|
||||||
|
tunnels.delete(key);
|
||||||
|
for (const connection of tunnel.connections.values()) connection.close();
|
||||||
|
try { tunnel.server.close(); } catch { /* already closing */ }
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
async open({ targetKey, remotePort, webContents }) {
|
||||||
|
const port = Number.parseInt(String(remotePort), 10);
|
||||||
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error('A valid remote port is required');
|
||||||
|
if (!targetKey) throw new Error('A relay target key is required');
|
||||||
|
if (!webContents || webContents.isDestroyed?.()) throw new Error('The desktop window is unavailable');
|
||||||
|
|
||||||
|
const key = `${webContents.id}|${targetKey}|${port}`;
|
||||||
|
const existing = tunnels.get(key);
|
||||||
|
if (existing) return { localPort: existing.localPort, reused: true };
|
||||||
|
|
||||||
|
const connections = new Map();
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
socket.setNoDelay(true);
|
||||||
|
socket.pause();
|
||||||
|
const connectionId = randomUUID();
|
||||||
|
const { port1, port2 } = createMessageChannel();
|
||||||
|
let closed = false;
|
||||||
|
const readyTimer = setTimeout(() => close(), CONNECTION_READY_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
clearTimeout(readyTimer);
|
||||||
|
connections.delete(connectionId);
|
||||||
|
try { port1.postMessage({ type: 'close' }); } catch { /* already closed */ }
|
||||||
|
try { socket.destroy(); } catch { /* already closed */ }
|
||||||
|
try { port1.close(); } catch { /* already closed */ }
|
||||||
|
};
|
||||||
|
connections.set(connectionId, { close });
|
||||||
|
|
||||||
|
port1.on('message', (event) => {
|
||||||
|
const message = messageData(event);
|
||||||
|
if (!message) return;
|
||||||
|
if (message.type === 'ready') {
|
||||||
|
clearTimeout(readyTimer);
|
||||||
|
socket.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === 'data' && message.data) {
|
||||||
|
socket.write(Buffer.from(message.data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === 'close') close();
|
||||||
|
});
|
||||||
|
port1.on('close', close);
|
||||||
|
port1.start?.();
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
if (closed) return;
|
||||||
|
port1.postMessage({ type: 'data', data: Uint8Array.from(chunk) });
|
||||||
|
});
|
||||||
|
socket.on('error', close);
|
||||||
|
socket.on('close', close);
|
||||||
|
|
||||||
|
try {
|
||||||
|
webContents.postMessage('openchamber:relay-dev-tunnel-connect', { connectionId, remotePort: port }, [port2]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn?.(`[dev-tunnel] failed to hand relay connection to renderer: ${error?.message || error}`);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const localPort = await listen(server);
|
||||||
|
server.on('error', (error) => logger.warn?.(`[dev-tunnel] relay listener failed: ${error?.message || error}`));
|
||||||
|
tunnels.set(key, { server, connections, localPort });
|
||||||
|
webContents.once?.('destroyed', () => closeTunnel(key));
|
||||||
|
return { localPort, reused: false };
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAll() {
|
||||||
|
for (const key of [...tunnels.keys()]) closeTunnel(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
closeForWebContents(webContentsId) {
|
||||||
|
let closed = 0;
|
||||||
|
const prefix = `${webContentsId}|`;
|
||||||
|
for (const key of [...tunnels.keys()]) {
|
||||||
|
if (!key.startsWith(prefix)) continue;
|
||||||
|
if (closeTunnel(key)) closed += 1;
|
||||||
|
}
|
||||||
|
return closed;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterEach, describe, expect, test } from 'bun:test';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { MessageChannel } from 'node:worker_threads';
|
||||||
|
import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs';
|
||||||
|
|
||||||
|
const bridges = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (bridges.length) bridges.pop().closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('relay dev tunnel bridge', () => {
|
||||||
|
test('pipes a local browser connection through a renderer-owned message port', async () => {
|
||||||
|
let nextPort;
|
||||||
|
const webContents = {
|
||||||
|
id: 7,
|
||||||
|
isDestroyed: () => false,
|
||||||
|
once: () => {},
|
||||||
|
postMessage: (_channel, payload, ports) => {
|
||||||
|
nextPort = ports[0];
|
||||||
|
nextPort.on('message', (message) => {
|
||||||
|
if (message.type !== 'data') return;
|
||||||
|
expect(Buffer.from(message.data).toString()).toContain('GET /docs HTTP/1.1');
|
||||||
|
nextPort.postMessage({ type: 'data', data: Buffer.from('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok') });
|
||||||
|
nextPort.postMessage({ type: 'close' });
|
||||||
|
});
|
||||||
|
nextPort.start();
|
||||||
|
expect(payload.remotePort).toBe(4322);
|
||||||
|
nextPort.postMessage({ type: 'ready' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel(), logger: { warn: () => {} } });
|
||||||
|
bridges.push(bridge);
|
||||||
|
const { localPort } = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents });
|
||||||
|
|
||||||
|
const response = await new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect({ host: '127.0.0.1', port: localPort }, () => socket.write('GET /docs HTTP/1.1\r\nHost: localhost\r\n\r\n'));
|
||||||
|
let data = '';
|
||||||
|
socket.on('data', (chunk) => { data += chunk; });
|
||||||
|
socket.on('close', () => resolve(data));
|
||||||
|
socket.on('error', reject);
|
||||||
|
});
|
||||||
|
expect(response).toContain('\r\n\r\nok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reuses one local listener for the same window, runtime, and port', async () => {
|
||||||
|
const webContents = { id: 9, isDestroyed: () => false, once: () => {}, postMessage: () => {} };
|
||||||
|
const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() });
|
||||||
|
bridges.push(bridge);
|
||||||
|
const first = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents });
|
||||||
|
const second = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents });
|
||||||
|
expect(second).toEqual({ localPort: first.localPort, reused: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tells the renderer when the local browser connection closes', async () => {
|
||||||
|
const rendererClosed = new Promise((resolve) => {
|
||||||
|
const webContents = {
|
||||||
|
id: 11,
|
||||||
|
isDestroyed: () => false,
|
||||||
|
once: () => {},
|
||||||
|
postMessage: (_channel, _payload, ports) => {
|
||||||
|
const rendererPort = ports[0];
|
||||||
|
rendererPort.on('message', (message) => {
|
||||||
|
if (message.type === 'close') resolve();
|
||||||
|
});
|
||||||
|
rendererPort.start();
|
||||||
|
rendererPort.postMessage({ type: 'ready' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() });
|
||||||
|
bridges.push(bridge);
|
||||||
|
void bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }).then(({ localPort }) => {
|
||||||
|
const socket = net.connect({ host: '127.0.0.1', port: localPort }, () => socket.destroy());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await rendererClosed;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closes only listeners owned by the requested desktop window', async () => {
|
||||||
|
const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() });
|
||||||
|
bridges.push(bridge);
|
||||||
|
const windowOne = { id: 21, isDestroyed: () => false, once: () => {}, postMessage: () => {} };
|
||||||
|
const windowTwo = { id: 22, isDestroyed: () => false, once: () => {}, postMessage: () => {} };
|
||||||
|
const first = await bridge.open({ targetKey: 'host:one', remotePort: 4322, webContents: windowOne });
|
||||||
|
const second = await bridge.open({ targetKey: 'host:two', remotePort: 4322, webContents: windowTwo });
|
||||||
|
|
||||||
|
expect(bridge.closeForWebContents(windowOne.id)).toBe(1);
|
||||||
|
await expect(new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect({ host: '127.0.0.1', port: first.localPort }, resolve);
|
||||||
|
socket.on('error', reject);
|
||||||
|
})).rejects.toThrow();
|
||||||
|
const remaining = await bridge.open({ targetKey: 'host:two', remotePort: 4322, webContents: windowTwo });
|
||||||
|
expect(remaining).toEqual({ localPort: second.localPort, reused: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -348,7 +348,12 @@ function App({ apis }: AppProps) {
|
|||||||
|
|
||||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||||
void refreshLinearAuthStatus(apis.linear, { force: true });
|
void refreshLinearAuthStatus(apis.linear, { force: true });
|
||||||
}, [apis.github, apis.linear, embeddedSessionChat, refreshGitHubAuthStatus, refreshLinearAuthStatus]);
|
// `apis` is the same object across an instance switch, so without the epoch
|
||||||
|
// this ran once for the whole app session and both statuses kept describing
|
||||||
|
// whichever instance happened to be connected at startup. `isConnected` is
|
||||||
|
// here to re-ask, not to gate: both integrations answer independently of
|
||||||
|
// OpenCode, but a switch can race the transport and the retry is deduped.
|
||||||
|
}, [apis.github, apis.linear, embeddedSessionChat, isConnected, refreshGitHubAuthStatus, refreshLinearAuthStatus, runtimeEndpointEpoch]);
|
||||||
|
|
||||||
useAppFontEffects();
|
useAppFontEffects();
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { toast } from '@/components/ui';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||||
import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel';
|
import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel';
|
||||||
|
import { BranchSelector } from '@/components/views/git/BranchSelector';
|
||||||
import { CommitSection } from '@/components/views/git/CommitSection';
|
import { CommitSection } from '@/components/views/git/CommitSection';
|
||||||
|
import { DirtyBranchSwitchDialog } from '@/components/views/git/DirtyBranchSwitchDialog';
|
||||||
import { SyncActions } from '@/components/views/git/SyncActions';
|
import { SyncActions } from '@/components/views/git/SyncActions';
|
||||||
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
@@ -19,6 +21,7 @@ import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
|
|||||||
import {
|
import {
|
||||||
useGitStore,
|
useGitStore,
|
||||||
useGitStatus,
|
useGitStatus,
|
||||||
|
useGitBranches,
|
||||||
useIsGitRepo,
|
useIsGitRepo,
|
||||||
useGitLoadingStatus,
|
useGitLoadingStatus,
|
||||||
} from '@/stores/useGitStore';
|
} from '@/stores/useGitStore';
|
||||||
@@ -65,6 +68,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
|||||||
const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null);
|
const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null);
|
||||||
const currentDirectory = gitDirectory ?? rootDirectory;
|
const currentDirectory = gitDirectory ?? rootDirectory;
|
||||||
const status = useGitStatus(currentDirectory || null);
|
const status = useGitStatus(currentDirectory || null);
|
||||||
|
const branches = useGitBranches(currentDirectory || null);
|
||||||
const isGitRepo = useIsGitRepo(currentDirectory || null);
|
const isGitRepo = useIsGitRepo(currentDirectory || null);
|
||||||
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
|
const isLoadingStatus = useGitLoadingStatus(currentDirectory || null);
|
||||||
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
|
||||||
@@ -104,6 +108,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
|||||||
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
||||||
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
||||||
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
||||||
|
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const changeEntries = React.useMemo(() => {
|
const changeEntries = React.useMemo(() => {
|
||||||
const files = status?.files ?? [];
|
const files = status?.files ?? [];
|
||||||
@@ -157,6 +162,55 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
|||||||
}
|
}
|
||||||
}, [currentDirectory, fetchBranches, fetchStatus, git, t]);
|
}, [currentDirectory, fetchBranches, fetchStatus, git, t]);
|
||||||
|
|
||||||
|
const localBranches = React.useMemo(
|
||||||
|
() => (branches?.all ?? []).filter((branch) => !branch.startsWith('remotes/')).sort(),
|
||||||
|
[branches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const remoteBranches = React.useMemo(
|
||||||
|
() => (branches?.all ?? [])
|
||||||
|
.filter((branch) => branch.startsWith('remotes/'))
|
||||||
|
.map((branch) => branch.replace(/^remotes\//, ''))
|
||||||
|
.sort(),
|
||||||
|
[branches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const performCheckout = React.useCallback(async (branch: string) => {
|
||||||
|
if (!currentDirectory) return;
|
||||||
|
const normalized = branch.replace(/^remotes\//, '');
|
||||||
|
try {
|
||||||
|
const result = await git.checkoutBranch(currentDirectory, normalized);
|
||||||
|
toast.success(t('gitView.toast.checkedOut', { name: result.branch || normalized }));
|
||||||
|
await refreshStatusAndBranches();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t('gitView.toast.checkoutFailed', { name: normalized }));
|
||||||
|
}
|
||||||
|
}, [currentDirectory, git, refreshStatusAndBranches, t]);
|
||||||
|
|
||||||
|
const handleCheckoutBranch = React.useCallback((branch: string) => {
|
||||||
|
const normalized = branch.replace(/^remotes\//, '');
|
||||||
|
if ((status?.files?.length ?? 0) > 0) {
|
||||||
|
setPendingDirtySwitchBranch(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void performCheckout(normalized);
|
||||||
|
}, [performCheckout, status?.files]);
|
||||||
|
|
||||||
|
const handleCreateBranch = React.useCallback(async (branch: string, remote?: GitRemote) => {
|
||||||
|
if (!currentDirectory) return;
|
||||||
|
try {
|
||||||
|
await git.createBranch(currentDirectory, branch, status?.current ?? 'HEAD');
|
||||||
|
await git.checkoutBranch(currentDirectory, branch);
|
||||||
|
if (remote) {
|
||||||
|
await git.gitPush(currentDirectory, { remote: remote.name, branch, options: ['--set-upstream'] });
|
||||||
|
}
|
||||||
|
await refreshStatusAndBranches();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t('gitView.toast.createBranchFailed'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [currentDirectory, git, refreshStatusAndBranches, status?.current, t]);
|
||||||
|
|
||||||
const refreshRemotes = React.useCallback(async () => {
|
const refreshRemotes = React.useCallback(async () => {
|
||||||
if (!currentDirectory) {
|
if (!currentDirectory) {
|
||||||
setRemotes([]);
|
setRemotes([]);
|
||||||
@@ -542,9 +596,19 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="min-w-0 flex-1 px-1">
|
<div className="min-w-0 flex-1 px-1">
|
||||||
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
|
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
|
||||||
<p className="truncate typography-micro text-muted-foreground">
|
<BranchSelector
|
||||||
{status?.current || currentDirectory}
|
currentBranch={status?.current}
|
||||||
</p>
|
localBranches={localBranches}
|
||||||
|
remoteBranches={remoteBranches}
|
||||||
|
branchInfo={branches?.branches}
|
||||||
|
currentBranchAhead={status?.ahead}
|
||||||
|
onCheckout={(branch) => void handleCheckoutBranch(branch)}
|
||||||
|
onCreate={handleCreateBranch}
|
||||||
|
remotes={effectiveRemotes}
|
||||||
|
disabled={isLoadingStatus}
|
||||||
|
directory={currentDirectory}
|
||||||
|
switchBlockedNotice={(status?.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SyncActions
|
<SyncActions
|
||||||
syncAction={syncAction}
|
syncAction={syncAction}
|
||||||
@@ -594,6 +658,61 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
|||||||
<MobileChangesState icon message={t('gitView.empty.cleanTitle')} description={t('mobile.changes.cleanDescription')} />
|
<MobileChangesState icon message={t('gitView.empty.cleanTitle')} description={t('mobile.changes.cleanDescription')} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<DirtyBranchSwitchDialog
|
||||||
|
open={pendingDirtySwitchBranch !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setPendingDirtySwitchBranch(null); }}
|
||||||
|
targetBranch={pendingDirtySwitchBranch ?? ''}
|
||||||
|
changedFileCount={status?.files?.length ?? 0}
|
||||||
|
onCommitAndSwitch={async (message, pushAfter) => {
|
||||||
|
const branch = pendingDirtySwitchBranch;
|
||||||
|
if (!branch || !currentDirectory) return;
|
||||||
|
const sourceBranch = status?.current ?? null;
|
||||||
|
await git.createGitCommit(currentDirectory, message, { addAll: true });
|
||||||
|
let pushedRemoteName: string | null = null;
|
||||||
|
if (pushAfter) {
|
||||||
|
const trackingRemoteName = status?.tracking?.split('/')[0];
|
||||||
|
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
|
||||||
|
try {
|
||||||
|
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
||||||
|
await git.gitPush(currentDirectory, status?.tracking
|
||||||
|
? { remote: remote.name }
|
||||||
|
: { remote: remote.name, branch: sourceBranch ?? undefined, options: ['--set-upstream'] });
|
||||||
|
pushedRemoteName = remote.name;
|
||||||
|
} catch {
|
||||||
|
toast.error(t('gitView.dirtySwitch.pushFailed'));
|
||||||
|
await refreshStatusAndBranches();
|
||||||
|
setPendingDirtySwitchBranch(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(sourceBranch
|
||||||
|
? pushedRemoteName
|
||||||
|
? t('gitView.toast.pushedToUpstream', { name: pushedRemoteName })
|
||||||
|
: t('gitView.dirtySwitch.committedNotPushed', { branch: sourceBranch })
|
||||||
|
: t('gitView.toast.commitCreated'));
|
||||||
|
await refreshStatusAndBranches();
|
||||||
|
setPendingDirtySwitchBranch(null);
|
||||||
|
await performCheckout(branch);
|
||||||
|
}}
|
||||||
|
onGenerateMessage={async () => {
|
||||||
|
if (!currentDirectory) return '';
|
||||||
|
const paths = (status?.files ?? []).map((file) => file.path).sort();
|
||||||
|
const { message } = await generateCommitMessage(currentDirectory, paths);
|
||||||
|
return message.subject?.trim() ?? '';
|
||||||
|
}}
|
||||||
|
onRevertAndSwitch={async () => {
|
||||||
|
const branch = pendingDirtySwitchBranch;
|
||||||
|
if (!branch || !currentDirectory) return;
|
||||||
|
await handleRevertAll((status?.files ?? []).map((file) => file.path));
|
||||||
|
const fresh = await git.getGitStatus(currentDirectory);
|
||||||
|
if (!fresh.isClean && (fresh.files?.length ?? 0) > 0) {
|
||||||
|
toast.error(t('gitView.dirtySwitch.revertIncomplete'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingDirtySwitchBranch(null);
|
||||||
|
await performCheckout(branch);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
|||||||
import { useGitStore } from '@/stores/useGitStore';
|
import { useGitStore } from '@/stores/useGitStore';
|
||||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
|
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||||
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
|
import { useQuotaStore } from '@/stores/useQuotaStore';
|
||||||
|
import { useMcpStore } from '@/stores/useMcpStore';
|
||||||
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
|
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
@@ -68,6 +75,22 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
|||||||
useGitHubPrStatusStore.getState().resetForRuntimeSwitch();
|
useGitHubPrStatusStore.getState().resetForRuntimeSwitch();
|
||||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||||
useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||||
|
// Linear and GitHub are authenticated on the instance, not in the browser.
|
||||||
|
// Left in place, the previous instance's login stayed visible and usable —
|
||||||
|
// its rail tab, its issue pickers, its work-status rows — against a runtime
|
||||||
|
// that has no such integration. `App` re-asks once the new instance answers.
|
||||||
|
useLinearAuthStore.getState().resetForRuntimeSwitch();
|
||||||
|
useGitHubAuthStore.getState().resetForRuntimeSwitch();
|
||||||
|
// Work-status readouts served from the instance: quotas, MCP servers, skills
|
||||||
|
// and agent memory. All were cached globally or by directory alone, so they
|
||||||
|
// reported the previous instance until something happened to refetch.
|
||||||
|
useQuotaStore.getState().resetForRuntimeSwitch();
|
||||||
|
useMcpStore.getState().resetForRuntimeSwitch();
|
||||||
|
useSkillsStore.getState().resetForRuntimeSwitch();
|
||||||
|
useAgentMemoryStore.getState().reset();
|
||||||
|
// The Linear team filter names a team in one workspace. Carried across, it
|
||||||
|
// filters the new instance's issue list down to nothing.
|
||||||
|
useUIStore.getState().applyLinearIssueListFiltersForRuntime();
|
||||||
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
queueMicrotask(() => void syncDesktopSettings());
|
queueMicrotask(() => void syncDesktopSettings());
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>exe.dev</title>
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="6.5" vector-effect="non-scaling-stroke" transform="translate(64 64) scale(.92 1.06) translate(-64 -64)">
|
||||||
|
<path d="M127.17 52.87c-.79-2.79-2.62-5.51-4.9-7.26-2.04-1.56-4.4-2.35-7.02-2.35-.79 0-1.62.08-2.45.22-2.72.49-4.31 1.28-6.47 2.65-.79.5-1.47.74-2.08.74-.36 0-.75-.08-1.18-.24-4.8-1.8-9.35-3.95-13.99-6.25.33-.21.64-.47.9-.79 1.23-1.5 1.45-4.14.74-5.94-1.14-2.94-4.75-4.27-7.68-4.37-3.35-.12-6.33 1.39-9.26 2.83l-.84.42a77.4 77.4 0 0 0-12.27-4.08c-3.5-.84-7.01-1.41-10.44-1.7-1.16-.1-6.48-.22-7.05-.2-9.23.32-17.75 3.22-24.14 8.24-2.48 1.95-4.43 4.29-5.86 6.11-1.61 2.06-2.9 4.1-3.88 6.1-.44-.04-.89-.08-1.34-.08-4.27 0-7.23 2.71-7.54 6.9l-.02.34c-.07 2.06.6 3.99 1.91 5.46A7.9 7.9 0 0 0 .7 63.54a8 8 0 0 0 1.86 6.11c1.57 1.86 3.71 2.91 6.06 3.12-.02 1.25.02 2.55.24 3.86 3.06 18.54 20.3 27.71 34.13 29.93 3.47.56 7.04.85 10.62.85 8.52 0 16.43-1.59 22.89-4.6 12.27-5.71 17.03-15.67 20.54-24.56.95-2.39 1.91-4.71 3.23-6.62.47-.67.73-.99.88-1.14.53-.09 1.15-.13 1.77-.13.98 0 1.94.12 2.62.32 2.54.75 6.02 1.78 9.52 1.78 1.34 0 2.61-.16 3.78-.45 6.57-1.72 8.08-9.79 8.39-12.23l.07-.54c.14-1.09.3-2.46.25-3.88-.04-.94-.16-1.75-.38-2.49"/>
|
||||||
|
<ellipse cx="28.36" cy="47.62" rx="4.88" ry="5.57"/>
|
||||||
|
<path d="M109.52 52.18c-.11-.48.15-.97.49-1.28.53-.48 1.42-1.02 2.04-1.33.71-.35 1.83-.32 2.48.09.55.35.62.55.63 1.21.03 1.53-1.25 1.56-2.43 1.86-.72.18-1.86.66-2.6.31-.28-.15-.49-.41-.61-.86M115.85 64.78c-.75 1.76-6.88.76-5.6-1.72l.36-.4c.43-.28 1.03-.26 1.57-.21 1.11.12 4.56.23 3.67 2.33M116.54 58.06c-.5 1.46-1.72 1.04-2.93.94-.73-.07-1.98-.01-2.55-.59-.22-.22-.34-.54-.28-.99.01-.12.1-.19.13-.3.16-.33.42-.61.77-.75.65-.27 1.67-.48 2.36-.56.78-.09 1.84.32 2.3.92.08.1.14.18.18.26.21.34.18.56.02 1.07"/>
|
||||||
|
<path d="M53.88 65.26c-2.9-2.26-7.22-2.84-10.77-2.22-.15 1.2-.22 2.52-.77 3.62-.58 1.14-1.82 1.26-2.44 2.2-1.01 1.54 1.69 5.58 2.63 6.8 1.44 1.84 2.92 2.66 5.26 2.65 1.03-.01 1.98.09 2.93-.28 1.09-.42 2.42-1.51 3.24-2.36 1.53-1.58 2.11-3.84 2.28-5.98.14-1.95-.83-3.24-2.36-4.43"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -2584,6 +2584,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
selectedDraftDirectory,
|
selectedDraftDirectory,
|
||||||
selectedDraftBranchLabel,
|
selectedDraftBranchLabel,
|
||||||
selectedDraftBranchIsKnown,
|
selectedDraftBranchIsKnown,
|
||||||
|
selectedDraftDirectoryHasUncommittedChanges,
|
||||||
projectRootBranchOption,
|
projectRootBranchOption,
|
||||||
worktreeBranchOptions,
|
worktreeBranchOptions,
|
||||||
draftBranchItems,
|
draftBranchItems,
|
||||||
@@ -2851,6 +2852,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
selectedDirectory={selectedDraftDirectory}
|
selectedDirectory={selectedDraftDirectory}
|
||||||
selectedBranchLabel={selectedDraftBranchLabel}
|
selectedBranchLabel={selectedDraftBranchLabel}
|
||||||
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
||||||
|
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
|
||||||
projectRootBranchOption={projectRootBranchOption}
|
projectRootBranchOption={projectRootBranchOption}
|
||||||
worktreeBranchOptions={worktreeBranchOptions}
|
worktreeBranchOptions={worktreeBranchOptions}
|
||||||
branchItems={draftBranchItems}
|
branchItems={draftBranchItems}
|
||||||
@@ -2865,6 +2867,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
<MobileDraftTargetTriggers
|
<MobileDraftTargetTriggers
|
||||||
selectedProject={selectedDraftProject}
|
selectedProject={selectedDraftProject}
|
||||||
selectedBranchLabel={selectedDraftBranchLabel}
|
selectedBranchLabel={selectedDraftBranchLabel}
|
||||||
|
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
|
||||||
showBranchSelector={shouldShowDraftBranchSelector}
|
showBranchSelector={shouldShowDraftBranchSelector}
|
||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
onOpenPicker={setMobileDraftPicker}
|
onOpenPicker={setMobileDraftPicker}
|
||||||
@@ -3276,6 +3279,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
selectedDirectory={selectedDraftDirectory}
|
selectedDirectory={selectedDraftDirectory}
|
||||||
selectedBranchLabel={selectedDraftBranchLabel}
|
selectedBranchLabel={selectedDraftBranchLabel}
|
||||||
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
||||||
|
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
|
||||||
projectRootBranchOption={projectRootBranchOption}
|
projectRootBranchOption={projectRootBranchOption}
|
||||||
worktreeBranchOptions={worktreeBranchOptions}
|
worktreeBranchOptions={worktreeBranchOptions}
|
||||||
branchItems={draftBranchItems}
|
branchItems={draftBranchItems}
|
||||||
|
|||||||
@@ -169,7 +169,9 @@ and the send path reading the same grammar.
|
|||||||
recorded before a queued write could resurrect it.
|
recorded before a queued write could resurrect it.
|
||||||
- `state/useDraftTarget.ts` — the draft can target a directory that does not
|
- `state/useDraftTarget.ts` — the draft can target a directory that does not
|
||||||
exist yet (a worktree being created). It must survive not appearing in the
|
exist yet (a worktree being created). It must survive not appearing in the
|
||||||
branch list, or the selector snaps back to the project root mid-creation.
|
branch list, or the selector snaps back to the project root mid-creation. It
|
||||||
|
also owns the advisory dirty state for the selected directory, clearing it as
|
||||||
|
soon as the target changes so a warning never names a previous branch.
|
||||||
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
|
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
|
||||||
state and registers its application shortcuts locally. The selectors only
|
state and registers its application shortcuts locally. The selectors only
|
||||||
consume their shared prefix while the draft target UI is mounted.
|
consume their shared prefix while the draft target UI is mounted.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
|
|||||||
import { normalizePath } from '../attachments/filePaths';
|
import { normalizePath } from '../attachments/filePaths';
|
||||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { getGitStatus } from '@/lib/gitApi';
|
||||||
|
|
||||||
/** How long a cached branch list is served before it is refreshed. */
|
/** How long a cached branch list is served before it is refreshed. */
|
||||||
const BRANCHES_SWR_TTL_MS = 30_000;
|
const BRANCHES_SWR_TTL_MS = 30_000;
|
||||||
@@ -98,6 +99,7 @@ export function useDraftTarget(enabled: boolean) {
|
|||||||
const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all);
|
const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all);
|
||||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||||
const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false);
|
const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false);
|
||||||
|
const [dirtyDraftDirectory, setDirtyDraftDirectory] = React.useState<string | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!enabled || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) {
|
if (!enabled || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) {
|
||||||
@@ -189,6 +191,35 @@ export function useDraftTarget(enabled: boolean) {
|
|||||||
[newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath],
|
[newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (
|
||||||
|
!enabled
|
||||||
|
|| !selectedDraftDirectory
|
||||||
|
|| selectedDraftProject?.kind === 'chat'
|
||||||
|
|| newSessionDraft?.pendingWorktreeRequestId
|
||||||
|
|| newSessionDraft?.bootstrapPendingDirectory
|
||||||
|
) {
|
||||||
|
setDirtyDraftDirectory(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setDirtyDraftDirectory(null);
|
||||||
|
getGitStatus(selectedDraftDirectory, { mode: 'light' })
|
||||||
|
.then((status) => {
|
||||||
|
if (!cancelled && (status.files?.length ?? 0) > 0) {
|
||||||
|
setDirtyDraftDirectory(selectedDraftDirectory);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setDirtyDraftDirectory(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftProject?.kind]);
|
||||||
|
|
||||||
const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => {
|
const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => {
|
||||||
const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
|
const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
|
||||||
return Boolean(
|
return Boolean(
|
||||||
@@ -306,6 +337,7 @@ export function useDraftTarget(enabled: boolean) {
|
|||||||
selectedDraftDirectory,
|
selectedDraftDirectory,
|
||||||
selectedDraftBranchLabel,
|
selectedDraftBranchLabel,
|
||||||
selectedDraftBranchIsKnown,
|
selectedDraftBranchIsKnown,
|
||||||
|
selectedDraftDirectoryHasUncommittedChanges: dirtyDraftDirectory === selectedDraftDirectory,
|
||||||
projectRootBranchOption,
|
projectRootBranchOption,
|
||||||
worktreeBranchOptions,
|
worktreeBranchOptions,
|
||||||
draftBranchItems,
|
draftBranchItems,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import React from 'react';
|
|||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
|
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -44,6 +45,7 @@ export interface DraftTargetProps {
|
|||||||
selectedDirectory: string | null;
|
selectedDirectory: string | null;
|
||||||
selectedBranchLabel: string | null;
|
selectedBranchLabel: string | null;
|
||||||
selectedBranchIsKnown: boolean;
|
selectedBranchIsKnown: boolean;
|
||||||
|
hasUncommittedChanges: boolean;
|
||||||
projectRootBranchOption: BranchOption | null;
|
projectRootBranchOption: BranchOption | null;
|
||||||
worktreeBranchOptions: readonly BranchOption[];
|
worktreeBranchOptions: readonly BranchOption[];
|
||||||
branchItems: readonly BranchOption[];
|
branchItems: readonly BranchOption[];
|
||||||
@@ -92,14 +94,39 @@ function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Desktop: inline project and branch selects. */
|
/** Desktop: inline project and branch selects. */
|
||||||
|
/** How long the dirty-directory tooltip announces itself before becoming hover-only. */
|
||||||
|
const DIRTY_TOOLTIP_FLASH_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the tooltip for a few seconds when the dirty state first appears, so
|
||||||
|
* the warning is seen without hovering, then hands control back to hover.
|
||||||
|
*/
|
||||||
|
function useDirtyFlashTooltip(hasUncommittedChanges: boolean) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!hasUncommittedChanges) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
const timer = window.setTimeout(() => setOpen(false), DIRTY_TOOLTIP_FLASH_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [hasUncommittedChanges]);
|
||||||
|
|
||||||
|
return { open, onOpenChange: setOpen };
|
||||||
|
}
|
||||||
|
|
||||||
export function DraftTargetSelectors(props: DraftTargetProps) {
|
export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const dirtyTooltip = useDirtyFlashTooltip(props.hasUncommittedChanges);
|
||||||
const {
|
const {
|
||||||
projects,
|
projects,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedDirectory,
|
selectedDirectory,
|
||||||
selectedBranchLabel,
|
selectedBranchLabel,
|
||||||
selectedBranchIsKnown,
|
selectedBranchIsKnown,
|
||||||
|
hasUncommittedChanges,
|
||||||
projectRootBranchOption,
|
projectRootBranchOption,
|
||||||
worktreeBranchOptions,
|
worktreeBranchOptions,
|
||||||
branchItems,
|
branchItems,
|
||||||
@@ -176,16 +203,32 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
|||||||
onValueChange={handleDirectoryChange}
|
onValueChange={handleDirectoryChange}
|
||||||
disableGlobalShortcuts
|
disableGlobalShortcuts
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
|
||||||
ref={worktreeTriggerRef}
|
<TooltipTrigger asChild>
|
||||||
onKeyDown={handlePickerKeyDown}
|
<SelectTrigger
|
||||||
size="sm"
|
ref={worktreeTriggerRef}
|
||||||
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
onKeyDown={handlePickerKeyDown}
|
||||||
>
|
size="sm"
|
||||||
<SelectValue>
|
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
||||||
{selectedBranchLabel ?? t('chat.chatInput.branch')}
|
>
|
||||||
</SelectValue>
|
{hasUncommittedChanges ? (
|
||||||
</SelectTrigger>
|
<Icon
|
||||||
|
name="alert"
|
||||||
|
className="size-3.5 shrink-0 text-[var(--status-warning)]"
|
||||||
|
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<SelectValue>
|
||||||
|
{selectedBranchLabel ?? t('chat.chatInput.branch')}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{hasUncommittedChanges ? (
|
||||||
|
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
|
||||||
|
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
|
||||||
|
</TooltipContent>
|
||||||
|
) : null}
|
||||||
|
</Tooltip>
|
||||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
|
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
|
||||||
{projectRootBranchOption ? (
|
{projectRootBranchOption ? (
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
@@ -228,11 +271,12 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
|||||||
|
|
||||||
/** Mobile: buttons that open the bottom sheets below. */
|
/** Mobile: buttons that open the bottom sheets below. */
|
||||||
export function MobileDraftTargetTriggers(
|
export function MobileDraftTargetTriggers(
|
||||||
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'theme'>
|
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'hasUncommittedChanges' | 'theme'>
|
||||||
& { onOpenPicker: (picker: 'project' | 'branch') => void },
|
& { onOpenPicker: (picker: 'project' | 'branch') => void },
|
||||||
) {
|
) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { selectedProject, selectedBranchLabel, showBranchSelector, theme, onOpenPicker } = props;
|
const { selectedProject, selectedBranchLabel, showBranchSelector, hasUncommittedChanges, theme, onOpenPicker } = props;
|
||||||
|
const dirtyTooltip = useDirtyFlashTooltip(hasUncommittedChanges);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-1.5 flex min-w-0 items-center gap-x-2 px-0.5">
|
<div className="mb-1.5 flex min-w-0 items-center gap-x-2 px-0.5">
|
||||||
@@ -247,14 +291,30 @@ export function MobileDraftTargetTriggers(
|
|||||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
{showBranchSelector ? (
|
{showBranchSelector ? (
|
||||||
<button
|
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
|
<button
|
||||||
onClick={() => onOpenPicker('branch')}
|
type="button"
|
||||||
>
|
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
|
||||||
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
|
onClick={() => onOpenPicker('branch')}
|
||||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
>
|
||||||
</button>
|
{hasUncommittedChanges ? (
|
||||||
|
<Icon
|
||||||
|
name="alert"
|
||||||
|
className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]"
|
||||||
|
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
|
||||||
|
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{hasUncommittedChanges ? (
|
||||||
|
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
|
||||||
|
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
|
||||||
|
</TooltipContent>
|
||||||
|
) : null}
|
||||||
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -347,6 +347,28 @@ the matching header dropdown:
|
|||||||
discovered relative to the active project. It does not wrap the call in
|
discovered relative to the active project. It does not wrap the call in
|
||||||
`runBackgroundNetworkTask`: the store already gates its own fetch.
|
`runBackgroundNetworkTask`: the store already gates its own fetch.
|
||||||
|
|
||||||
|
Usage waits for the instance to say it is initialised. Quota providers report
|
||||||
|
themselves as configured only once the instance can read their credentials,
|
||||||
|
which on a remote instance is not true when the UI mounts — a fetch fired at
|
||||||
|
mount gets "nothing configured" for every provider, and since each one then has
|
||||||
|
a result, nothing asks again until the three-minute refresh. That is why Usage
|
||||||
|
could stay missing from the panel until Settings -> Usage forced a fresh fetch.
|
||||||
|
`useQuotaStore.ensureLoadedForRuntime` owns both the readiness rule and the
|
||||||
|
once-per-instance bookkeeping, so every caller can ask on each connection
|
||||||
|
change.
|
||||||
|
|
||||||
|
### These readouts belong to the connected instance
|
||||||
|
|
||||||
|
Quotas, MCP status, skills, agent memory and the Linear/GitHub logins are all
|
||||||
|
served by whichever OpenChamber instance is connected, and each was cached
|
||||||
|
globally or by directory alone — which two instances can share. A switch left
|
||||||
|
the previous instance's answers on screen, and its Linear login usable against
|
||||||
|
a runtime that has no Linear. `apps/runtimeEndpointReset.ts` now drops all of
|
||||||
|
them, each store guarding its own in-flight requests with a generation so a
|
||||||
|
response for the previous instance cannot land in the new one. The MCP and
|
||||||
|
skills effects take `isConnected` as a dependency — not a gate — because
|
||||||
|
`directory` alone does not change when both instances hold the same path.
|
||||||
|
|
||||||
The panel now performs these itself, silently and through the
|
The panel now performs these itself, silently and through the
|
||||||
background-network gate, so it cannot compete with chat bootstrap traffic for
|
background-network gate, so it cannot compete with chat bootstrap traffic for
|
||||||
sockets. Usage additionally provides an explicit refresh action in its section
|
sockets. Usage additionally provides an explicit refresh action in its section
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { resolveProjectContextId } from '@/lib/projectContextApi';
|
|||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { useMobileAppActions } from '@/apps/mobileAppContext';
|
import { useMobileAppActions } from '@/apps/mobileAppContext';
|
||||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
|
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
|
||||||
import { useReportWorkStatusPresence } from './presenceContext';
|
import { useReportWorkStatusPresence } from './presenceContext';
|
||||||
@@ -61,9 +62,15 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
|||||||
// here: `loadSkills` already gates its own fetch, and wrapping it again
|
// here: `loadSkills` already gates its own fetch, and wrapping it again
|
||||||
// would hold a second slot idle for the length of the first.
|
// would hold a second slot idle for the length of the first.
|
||||||
const loadSkills = useSkillsStore((state) => state.loadSkills);
|
const loadSkills = useSkillsStore((state) => state.loadSkills);
|
||||||
|
// `isConnected` is a dependency, not a gate: skills are discovered on the
|
||||||
|
// connected instance and their caches are dropped when instances switch, so
|
||||||
|
// the count has to be asked for again once the new instance is up. Two
|
||||||
|
// instances can hold the same project path, which leaves `directory`
|
||||||
|
// unchanged across a switch.
|
||||||
|
const isConnected = useConfigStore((state) => state.isConnected);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void loadSkills();
|
void loadSkills();
|
||||||
}, [directory, loadSkills]);
|
}, [directory, isConnected, loadSkills]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What this session carries. Read from the server
|
* What this session carries. Read from the server
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { useMcpStore } from '@/stores/useMcpStore';
|
import { useMcpStore } from '@/stores/useMcpStore';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { McpIcon } from '@/components/icons/McpIcon';
|
import { McpIcon } from '@/components/icons/McpIcon';
|
||||||
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -28,6 +29,7 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
|
|||||||
const ensureMcpFresh = useMcpStore((state) => state.ensureFresh);
|
const ensureMcpFresh = useMcpStore((state) => state.ensureFresh);
|
||||||
const connect = useMcpStore((state) => state.connect);
|
const connect = useMcpStore((state) => state.connect);
|
||||||
const disconnect = useMcpStore((state) => state.disconnect);
|
const disconnect = useMcpStore((state) => state.disconnect);
|
||||||
|
const isConnected = useConfigStore((state) => state.isConnected);
|
||||||
const [busyServer, setBusyServer] = React.useState<string | null>(null);
|
const [busyServer, setBusyServer] = React.useState<string | null>(null);
|
||||||
|
|
||||||
// The panel must not depend on the header dropdown having been mounted or
|
// The panel must not depend on the header dropdown having been mounted or
|
||||||
@@ -35,9 +37,12 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
|
|||||||
// compete with chat bootstrap traffic for sockets. The section remounts on
|
// compete with chat bootstrap traffic for sockets. The section remounts on
|
||||||
// every session switch, so it only asks for a status that is missing or
|
// every session switch, so it only asks for a status that is missing or
|
||||||
// older than a minute; connect/disconnect/auth refresh on their own.
|
// older than a minute; connect/disconnect/auth refresh on their own.
|
||||||
|
// `isConnected` is a dependency, not a gate: MCP status is cached by
|
||||||
|
// directory alone and dropped on an instance switch, and two instances can
|
||||||
|
// hold the same project path — so the switch itself has to trigger the ask.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void runBackgroundNetworkTask(() => ensureMcpFresh({ directory, silent: true, maxAgeMs: MCP_STATUS_MAX_AGE_MS }));
|
void runBackgroundNetworkTask(() => ensureMcpFresh({ directory, silent: true, maxAgeMs: MCP_STATUS_MAX_AGE_MS }));
|
||||||
}, [directory, ensureMcpFresh]);
|
}, [directory, ensureMcpFresh, isConnected]);
|
||||||
|
|
||||||
const mcpServers = React.useMemo(
|
const mcpServers = React.useMemo(
|
||||||
() => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)),
|
() => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)),
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ export const WorkStatusUsageSection: React.FC = () => {
|
|||||||
const groups = useUsageProviderGroups();
|
const groups = useUsageProviderGroups();
|
||||||
const displayMode = useQuotaStore((state) => state.displayMode);
|
const displayMode = useQuotaStore((state) => state.displayMode);
|
||||||
const isLoading = useQuotaStore((state) => state.isLoading);
|
const isLoading = useQuotaStore((state) => state.isLoading);
|
||||||
const quotaResults = useQuotaStore((state) => state.results);
|
|
||||||
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
|
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
|
||||||
const fetchQuotas = useQuotaStore((state) => state.fetchQuotas);
|
const fetchQuotas = useQuotaStore((state) => state.fetchQuotas);
|
||||||
|
const ensureQuotasLoadedForRuntime = useQuotaStore((state) => state.ensureLoadedForRuntime);
|
||||||
|
const isInitialized = useConfigStore((state) => state.isInitialized);
|
||||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||||
|
|
||||||
@@ -54,17 +55,13 @@ export const WorkStatusUsageSection: React.FC = () => {
|
|||||||
|
|
||||||
// `useQuotaAutoRefresh` only schedules an interval — it never performs the
|
// `useQuotaAutoRefresh` only schedules an interval — it never performs the
|
||||||
// first fetch. That was owned by the header dropdown's open handler, so the
|
// first fetch. That was owned by the header dropdown's open handler, so the
|
||||||
// panel stayed empty until the user opened it. Kick off the initial load for
|
// panel stayed empty until the user opened it. `ensureLoadedForRuntime` owns
|
||||||
// any enabled provider that has not reported yet, background-gated so it
|
// the once-per-instance load and its readiness rule; asking again is a no-op,
|
||||||
// cannot compete with chat bootstrap traffic.
|
// so this is safe to run on every connection change.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isLoading || dropdownProviderIds.length === 0) return;
|
if (!isInitialized) return;
|
||||||
const missingProvider = dropdownProviderIds.some(
|
void runBackgroundNetworkTask(() => ensureQuotasLoadedForRuntime());
|
||||||
(providerId) => !quotaResults.some((result) => result.providerId === providerId),
|
}, [ensureQuotasLoadedForRuntime, isInitialized]);
|
||||||
);
|
|
||||||
if (!missingProvider) return;
|
|
||||||
void runBackgroundNetworkTask(() => fetchQuotas(dropdownProviderIds));
|
|
||||||
}, [dropdownProviderIds, fetchQuotas, isLoading, quotaResults]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (groups.length === 0) return;
|
if (groups.length === 0) return;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { toast } from '@/components/ui';
|
|||||||
import { isElectronShell, isDesktopShell } from '@/lib/desktop';
|
import { isElectronShell, isDesktopShell } from '@/lib/desktop';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
desktopHostProbe,
|
desktopHostProbe,
|
||||||
@@ -37,6 +38,14 @@ import {
|
|||||||
resolveCurrentDesktopHost,
|
resolveCurrentDesktopHost,
|
||||||
runtimeKeyForDesktopHost,
|
runtimeKeyForDesktopHost,
|
||||||
} from '@/lib/desktopCurrentHost';
|
} from '@/lib/desktopCurrentHost';
|
||||||
|
import {
|
||||||
|
getDesktopHostStatusSnapshot,
|
||||||
|
probeDesktopHosts,
|
||||||
|
setDesktopHostStatus,
|
||||||
|
pruneDesktopHostStatuses,
|
||||||
|
subscribeDesktopHostStatuses,
|
||||||
|
type DesktopHostStatus,
|
||||||
|
} from '@/lib/desktopHostStatus';
|
||||||
import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore';
|
import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore';
|
||||||
import { adoptRelayTunnel } from '@/lib/relay/runtime-tunnel';
|
import { adoptRelayTunnel } from '@/lib/relay/runtime-tunnel';
|
||||||
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
||||||
@@ -52,17 +61,7 @@ import {
|
|||||||
const SSH_CONNECT_TIMEOUT_MS = 90_000;
|
const SSH_CONNECT_TIMEOUT_MS = 90_000;
|
||||||
const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled';
|
const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled';
|
||||||
|
|
||||||
type HostStatus = {
|
type HostStatus = DesktopHostStatus;
|
||||||
status: HostProbeResult['status'];
|
|
||||||
latencyMs: number;
|
|
||||||
/** Which transport the successful probe used (multi-transport hosts). */
|
|
||||||
via?: 'relay';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Last known statuses survive the dropdown unmounting (it remounts on every
|
|
||||||
// open). Rows show the previous result immediately — refreshed quietly by the
|
|
||||||
// open-probe — instead of shouting "Unknown" at the user for a few seconds.
|
|
||||||
const lastKnownHostStatuses: Record<string, HostStatus> = {};
|
|
||||||
|
|
||||||
type HostDisplayStatus = HostProbeResult['status'] | 'checking' | null;
|
type HostDisplayStatus = HostProbeResult['status'] | 'checking' | null;
|
||||||
|
|
||||||
@@ -247,15 +246,17 @@ export function DesktopHostSwitcherDialog({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||||
|
const isRuntimeConnected = useConfigStore((state) => state.isConnected);
|
||||||
|
|
||||||
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
|
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
|
||||||
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
|
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
|
||||||
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>(() => ({ ...lastKnownHostStatuses }));
|
// Statuses live outside this component: startup warms them, and the dropdown
|
||||||
React.useEffect(() => {
|
// remounts on every open — holding them here is what made each open start
|
||||||
Object.assign(lastKnownHostStatuses, statusById);
|
// from nothing and show "Checking" on rows the app already knew about.
|
||||||
}, [statusById]);
|
const statusSnapshot = React.useSyncExternalStore(subscribeDesktopHostStatuses, getDesktopHostStatusSnapshot, getDesktopHostStatusSnapshot);
|
||||||
|
const statusById = statusSnapshot.byHostId;
|
||||||
|
const isProbing = statusSnapshot.isProbing;
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
const [isProbing, setIsProbing] = React.useState(false);
|
|
||||||
const [isSaving, setIsSaving] = React.useState(false);
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
const [switchingHostId, setSwitchingHostId] = React.useState<string | null>(null);
|
const [switchingHostId, setSwitchingHostId] = React.useState<string | null>(null);
|
||||||
const [sshHostIds, setSshHostIds] = React.useState<Record<string, true>>({});
|
const [sshHostIds, setSshHostIds] = React.useState<Record<string, true>>({});
|
||||||
@@ -347,6 +348,10 @@ export function DesktopHostSwitcherDialog({
|
|||||||
nextSshHostIds[instance.id] = true;
|
nextSshHostIds[instance.id] = true;
|
||||||
}
|
}
|
||||||
setConfigHosts(cfg.hosts || []);
|
setConfigHosts(cfg.hosts || []);
|
||||||
|
// Config is the authoritative host list: drop statuses for instances the
|
||||||
|
// user removed. Doing this from a probe run instead would clear entries
|
||||||
|
// every time a run started before the config had finished loading.
|
||||||
|
pruneDesktopHostStatuses((cfg.hosts || []).map((host) => host.id));
|
||||||
setDefaultHostId(cfg.defaultHostId ?? null);
|
setDefaultHostId(cfg.defaultHostId ?? null);
|
||||||
setSshHostIds(nextSshHostIds);
|
setSshHostIds(nextSshHostIds);
|
||||||
setSshStatusesById(sshStatusMap);
|
setSshStatusesById(sshStatusMap);
|
||||||
@@ -362,43 +367,7 @@ export function DesktopHostSwitcherDialog({
|
|||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
|
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
|
||||||
if (!isDesktopShell()) return;
|
await probeDesktopHosts(hosts);
|
||||||
setIsProbing(true);
|
|
||||||
try {
|
|
||||||
const localClientToken = await getLocalClientToken();
|
|
||||||
const results = await Promise.all(
|
|
||||||
hosts.map(async (h) => {
|
|
||||||
const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || '');
|
|
||||||
const probeRelayLeg = async (): Promise<HostStatus> => {
|
|
||||||
const res = await probeRelayDesktopHost(h.relay!, { clientToken, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
|
||||||
return { status: res.status, latencyMs: res.latencyMs, ...(res.status === 'ok' ? { via: 'relay' as const } : {}) };
|
|
||||||
};
|
|
||||||
// Relay-only host: no HTTP address — probe through the E2EE tunnel.
|
|
||||||
if (h.relay && !h.apiUrl) {
|
|
||||||
return [h.id, await probeRelayLeg()] as const;
|
|
||||||
}
|
|
||||||
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url);
|
|
||||||
if (!url) {
|
|
||||||
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
|
|
||||||
}
|
|
||||||
const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.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 (isBlockedHostStatus(res.status) && h.relay) {
|
|
||||||
const relayStatus = await probeRelayLeg();
|
|
||||||
if (relayStatus.status === 'ok') return [h.id, relayStatus] as const;
|
|
||||||
}
|
|
||||||
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const next: Record<string, HostStatus> = {};
|
|
||||||
for (const [id, val] of results) {
|
|
||||||
next[id] = val;
|
|
||||||
}
|
|
||||||
setStatusById(next);
|
|
||||||
} finally {
|
|
||||||
setIsProbing(false);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -514,7 +483,7 @@ export function DesktopHostSwitcherDialog({
|
|||||||
relayProbeTunnel = 'tunnel' in probe ? probe.tunnel : undefined;
|
relayProbeTunnel = 'tunnel' in probe ? probe.tunnel : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setStatusById((prev) => ({ ...prev, [host.id]: finalStatus }));
|
setDesktopHostStatus(host.id, finalStatus);
|
||||||
|
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
|
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
|
||||||
@@ -620,10 +589,7 @@ export function DesktopHostSwitcherDialog({
|
|||||||
if (host.id !== LOCAL_HOST_ID && isDesktopShell()) {
|
if (host.id !== LOCAL_HOST_ID && isDesktopShell()) {
|
||||||
setSwitchingHostId(host.id);
|
setSwitchingHostId(host.id);
|
||||||
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||||
setStatusById((prev) => ({
|
setDesktopHostStatus(host.id, { status: probe.status, latencyMs: probe.latencyMs });
|
||||||
...prev,
|
|
||||||
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (isBlockedHostStatus(probe.status)) {
|
if (isBlockedHostStatus(probe.status)) {
|
||||||
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
|
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
|
||||||
@@ -863,12 +829,17 @@ export function DesktopHostSwitcherDialog({
|
|||||||
const status = statusById[host.id] || null;
|
const status = statusById[host.id] || null;
|
||||||
const sshStatus = sshStatusesById[host.id] || null;
|
const sshStatus = sshStatusesById[host.id] || null;
|
||||||
// While a probe runs, keep showing the last known result (quiet
|
// While a probe runs, keep showing the last known result (quiet
|
||||||
// refresh); only fall back to "Checking" when there has never
|
// refresh — the header's refresh icon is the spinner); only fall
|
||||||
// been one. "Unknown" is never shown — an unprobed host is by
|
// back to "Checking" when there has never been one. "Unknown" is
|
||||||
// definition being checked.
|
// never shown — an unprobed host is by definition being checked.
|
||||||
|
//
|
||||||
|
// The instance the app is connected to never says "Checking":
|
||||||
|
// the live connection already answers the question a probe would
|
||||||
|
// ask, and reporting otherwise reads as the app not knowing where
|
||||||
|
// it is. A real probe result still wins — it carries the ping.
|
||||||
const statusKind: HostDisplayStatus = isSsh
|
const statusKind: HostDisplayStatus = isSsh
|
||||||
? sshPhaseToHostStatus(sshStatus?.phase)
|
? sshPhaseToHostStatus(sshStatus?.phase)
|
||||||
: (status?.status ?? 'checking');
|
: (status?.status ?? (isActive && isRuntimeConnected ? 'ok' : 'checking'));
|
||||||
const isEditing = editingId === host.id;
|
const isEditing = editingId === host.id;
|
||||||
const effectiveUrl = isLocal ? localOrigin : (normalizeHostUrl(host.url) || host.url);
|
const effectiveUrl = isLocal ? localOrigin : (normalizeHostUrl(host.url) || host.url);
|
||||||
const displayLabel = host.id === LOCAL_HOST_ID
|
const displayLabel = host.id === LOCAL_HOST_ID
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({
|
|||||||
type DesktopServicesMenuProps = {
|
type DesktopServicesMenuProps = {
|
||||||
isDesktopApp: boolean;
|
isDesktopApp: boolean;
|
||||||
currentInstanceLabel: string;
|
currentInstanceLabel: string;
|
||||||
compactCurrentInstanceLabel: string;
|
|
||||||
currentInstanceIsLocal: boolean;
|
currentInstanceIsLocal: boolean;
|
||||||
isDesktopServicesOpen: boolean;
|
isDesktopServicesOpen: boolean;
|
||||||
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@@ -139,7 +138,6 @@ type DesktopServicesMenuProps = {
|
|||||||
const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
||||||
isDesktopApp,
|
isDesktopApp,
|
||||||
currentInstanceLabel,
|
currentInstanceLabel,
|
||||||
compactCurrentInstanceLabel,
|
|
||||||
currentInstanceIsLocal,
|
currentInstanceIsLocal,
|
||||||
isDesktopServicesOpen,
|
isDesktopServicesOpen,
|
||||||
setIsDesktopServicesOpen,
|
setIsDesktopServicesOpen,
|
||||||
@@ -171,12 +169,12 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
|
|||||||
: t('header.services.open')}
|
: t('header.services.open')}
|
||||||
className={cn(
|
className={cn(
|
||||||
DESKTOP_HEADER_ICON_BUTTON_CLASS,
|
DESKTOP_HEADER_ICON_BUTTON_CLASS,
|
||||||
isDesktopApp ? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8'
|
isDesktopApp ? 'w-auto max-w-[20rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon name="server" className="h-[18px] w-[18px]" />
|
<Icon name="server" className="h-[18px] w-[18px]" />
|
||||||
{isDesktopApp ? (
|
{isDesktopApp ? (
|
||||||
<span className="truncate typography-ui-label font-medium text-foreground">{compactCurrentInstanceLabel}</span>
|
<span className="truncate typography-ui-label font-medium text-foreground">{currentInstanceLabel}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -251,27 +249,6 @@ const isSameContextUsage = (
|
|||||||
&& (a.lastMessageId ?? '') === (b.lastMessageId ?? '');
|
&& (a.lastMessageId ?? '') === (b.lastMessageId ?? '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCompactHeaderLabel = (value: string): string => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const words = trimmed.split(/\s+/).filter(Boolean);
|
|
||||||
if (words.length >= 2) {
|
|
||||||
const first = words[0];
|
|
||||||
const second = words[1].slice(0, 3);
|
|
||||||
const shortTwoWord = `${first} ${second}`.trim();
|
|
||||||
if (words.length > 2 || shortTwoWord.length < trimmed.length) {
|
|
||||||
return `${shortTwoWord}...`;
|
|
||||||
}
|
|
||||||
return shortTwoWord;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const normalize = (value: string): string => {
|
const normalize = (value: string): string => {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
const replaced = value.replace(/\\/g, '/');
|
const replaced = value.replace(/\\/g, '/');
|
||||||
@@ -447,7 +424,6 @@ export const Header: React.FC = () => {
|
|||||||
const [remoteUpdateInfo, setRemoteUpdateInfo] = React.useState<UpdateInfo | null>(null);
|
const [remoteUpdateInfo, setRemoteUpdateInfo] = React.useState<UpdateInfo | null>(null);
|
||||||
const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false);
|
const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false);
|
||||||
const [remoteUpdateError, setRemoteUpdateError] = React.useState<string | null>(null);
|
const [remoteUpdateError, setRemoteUpdateError] = React.useState<string | null>(null);
|
||||||
const compactCurrentInstanceLabel = React.useMemo(() => formatCompactHeaderLabel(currentInstanceLabel), [currentInstanceLabel]);
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
// While the work-status panel is on screen it already reports the project,
|
// While the work-status panel is on screen it already reports the project,
|
||||||
// the branch and the context fill — three paces away in the same window.
|
// the branch and the context fill — three paces away in the same window.
|
||||||
@@ -1293,7 +1269,6 @@ export const Header: React.FC = () => {
|
|||||||
<DesktopServicesMenu
|
<DesktopServicesMenu
|
||||||
isDesktopApp={isDesktopApp}
|
isDesktopApp={isDesktopApp}
|
||||||
currentInstanceLabel={currentInstanceLabel}
|
currentInstanceLabel={currentInstanceLabel}
|
||||||
compactCurrentInstanceLabel={compactCurrentInstanceLabel}
|
|
||||||
currentInstanceIsLocal={currentInstanceIsLocal}
|
currentInstanceIsLocal={currentInstanceIsLocal}
|
||||||
isDesktopServicesOpen={isDesktopServicesOpen}
|
isDesktopServicesOpen={isDesktopServicesOpen}
|
||||||
setIsDesktopServicesOpen={setIsDesktopServicesOpen}
|
setIsDesktopServicesOpen={setIsDesktopServicesOpen}
|
||||||
|
|||||||
@@ -5,21 +5,23 @@ import { toast } from '@/components/ui';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
|
|
||||||
type ProviderId = 'ollama-cloud' | 'cursor';
|
type ProviderId = 'exe-dev' | 'ollama-cloud' | 'cursor';
|
||||||
type Status = { configured: boolean; secretMasked?: string };
|
type Status = { configured: boolean; secretMasked?: string };
|
||||||
|
type CredentialPayload = { usageToken?: string; cookie?: string; accessToken?: string; refreshToken?: string };
|
||||||
|
const EXE_DEV_TOKEN_COMMAND = `ssh exe.dev "ssh-key generate-api-key --label=openchamber --exp=30d --cmds='billing credits usage'"`;
|
||||||
|
|
||||||
export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: string }> = ({ providerId, providerName }) => {
|
export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: string }> = ({ providerId, providerName }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [status, setStatus] = React.useState<Status | null>(null);
|
const [status, setStatus] = React.useState<Status | null>(null);
|
||||||
const [values, setValues] = React.useState<Record<string, string>>({});
|
const [values, setValues] = React.useState<CredentialPayload>({});
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
const route = `/api/quota/credentials/${providerId}`;
|
const route = `/api/quota/credentials/${providerId}`;
|
||||||
React.useEffect(() => { void runtimeFetch(route).then(async (response) => {
|
React.useEffect(() => { void runtimeFetch(route).then(async (response) => {
|
||||||
if (!response.ok) throw new Error();
|
if (!response.ok) throw new Error();
|
||||||
const next = await response.json() as Status;
|
const next: Status = await response.json();
|
||||||
setStatus(next); setValues({});
|
setStatus(next); setValues({});
|
||||||
}).catch(() => setStatus({ configured: false })); }, [route]);
|
}).catch(() => setStatus({ configured: false })); }, [route]);
|
||||||
const request = async (path: string, method: string, body?: object) => {
|
const request = async (path: string, method: string, body?: CredentialPayload) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await runtimeFetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
const response = await runtimeFetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
||||||
@@ -31,11 +33,16 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName:
|
|||||||
} catch (error) { toast.error(error instanceof Error && error.message ? error.message : t('settings.providers.page.openCodeGo.saveFailed')); }
|
} catch (error) { toast.error(error instanceof Error && error.message ? error.message : t('settings.providers.page.openCodeGo.saveFailed')); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
|
const field = (name: keyof CredentialPayload, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
|
||||||
return <div data-settings-item={`usage.${providerId}-credentials`} className="mb-8">
|
return <div data-settings-item={`usage.${providerId}-credentials`} className="mb-8">
|
||||||
<div className="mb-1 px-1"><h3 className="typography-ui-header font-medium text-foreground">{providerName}</h3></div>
|
<div className="mb-1 px-1"><h3 className="typography-ui-header font-medium text-foreground">{providerName}</h3></div>
|
||||||
<section className="space-y-3 px-2 pb-2 pt-0">
|
<section className="space-y-3 px-2 pb-2 pt-0">
|
||||||
|
{providerId === 'exe-dev' && <div className="space-y-1.5">
|
||||||
|
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.quotaCredentials.exeDevTokenInstructions')}</p>
|
||||||
|
<code className="typography-code block whitespace-pre-wrap break-all rounded bg-muted/50 px-2 py-1.5 text-xs text-foreground">{EXE_DEV_TOKEN_COMMAND}</code>
|
||||||
|
</div>}
|
||||||
{providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')}
|
{providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')}
|
||||||
|
{providerId === 'exe-dev' && field('usageToken', t('settings.providers.page.quotaCredentials.usageToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
|
||||||
{providerId === 'cursor' && field('accessToken', t('settings.providers.page.quotaCredentials.accessToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
|
{providerId === 'cursor' && field('accessToken', t('settings.providers.page.quotaCredentials.accessToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
|
||||||
{providerId === 'cursor' && field('refreshToken', t('settings.providers.page.quotaCredentials.refreshToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
|
{providerId === 'cursor' && field('refreshToken', t('settings.providers.page.quotaCredentials.refreshToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const UsagePage: React.FC = () => {
|
|||||||
? selectedResult.error
|
? selectedResult.error
|
||||||
: null;
|
: null;
|
||||||
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
|
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
|
||||||
const hasCredentialsForm = selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
|
const hasCredentialsForm = selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
|
||||||
const handleDropdownToggle = React.useCallback((enabled: boolean) => {
|
const handleDropdownToggle = React.useCallback((enabled: boolean) => {
|
||||||
if (!selectedProviderId) {
|
if (!selectedProviderId) {
|
||||||
return;
|
return;
|
||||||
@@ -204,7 +204,7 @@ export const UsagePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
|
{(selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
|
||||||
<QuotaCredentials providerId={selectedProviderId} providerName={providerName} />
|
<QuotaCredentials providerId={selectedProviderId} providerName={providerName} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -285,7 +285,14 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
className={cn(
|
||||||
|
'flex min-w-0 flex-1 items-center gap-1.5 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-[padding]',
|
||||||
|
// Reserve hover space for the absolute action buttons,
|
||||||
|
// matching the collapse-toggle branch below.
|
||||||
|
isRepo && !hideDirectoryControls
|
||||||
|
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
|
||||||
|
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
|
||||||
|
)}
|
||||||
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
|
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
|
||||||
>
|
>
|
||||||
<ProjectHeaderIdentity id={id} projectLabel={projectLabel} projectIcon={projectIcon} projectColor={projectColor} projectIconImage={projectIconImage} projectIconBackground={projectIconBackground} />
|
<ProjectHeaderIdentity id={id} projectLabel={projectLabel} projectIcon={projectIcon} projectColor={projectColor} projectIconImage={projectIconImage} projectIconBackground={projectIconBackground} />
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
|
import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { loadMonoFont } from '@/lib/fontLoader';
|
||||||
|
import type { MonoFontOption } from '@/lib/fontOptions';
|
||||||
import type { TerminalTheme } from '@/lib/terminalTheme';
|
import type { TerminalTheme } from '@/lib/terminalTheme';
|
||||||
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
||||||
import {
|
import {
|
||||||
@@ -27,20 +29,24 @@ const loadGhostty = (): Promise<GhosttyRuntime> =>
|
|||||||
ghostty: await module.Ghostty.load(),
|
ghostty: await module.Ghostty.load(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// The web entry defers its ~2 MB Nerd Font download until a terminal actually
|
// Wait briefly for both the selected mono font and the web entry's deferred
|
||||||
// mounts (see the `__openchamberEnsureNerdFonts` hook in index.html). Wait for
|
// Nerd Fonts before Ghostty measures glyphs. A cold CDN fetch must not block
|
||||||
// it with a short bound so a cached font is in place before the glyph atlas is
|
// opening the terminal, so the renderer starts after the bound and is rebuilt
|
||||||
// built, while a cold CDN fetch never blocks the terminal from opening; the
|
// once the fonts arrive. Runtimes without the Nerd Font hook resolve it at once.
|
||||||
// runtimes without the hook (VS Code, mobile) resolve immediately.
|
const TERMINAL_FONT_WAIT_MS = 2000;
|
||||||
const NERD_FONT_WAIT_MS = 2000;
|
const loadNerdFonts = (): Promise<void> =>
|
||||||
const ensureNerdFonts = (): Promise<void> => {
|
Promise.resolve(window.__openchamberEnsureNerdFonts?.()).catch(() => undefined);
|
||||||
if (typeof window === 'undefined') return Promise.resolve();
|
|
||||||
const loader = (window as typeof window & { __openchamberEnsureNerdFonts?: () => Promise<void> }).__openchamberEnsureNerdFonts;
|
const waitForTerminalFonts = (font: MonoFontOption) => {
|
||||||
if (typeof loader !== 'function') return Promise.resolve();
|
const loaded = Promise.all([loadMonoFont(font), loadNerdFonts()]).then(() => undefined);
|
||||||
return Promise.race([
|
const loadedBeforeTimeout = new Promise<boolean>((resolve) => {
|
||||||
Promise.resolve(loader()).catch(() => undefined),
|
const timeout = setTimeout(() => resolve(false), TERMINAL_FONT_WAIT_MS);
|
||||||
new Promise<void>((resolve) => setTimeout(resolve, NERD_FONT_WAIT_MS)),
|
void loaded.then(() => {
|
||||||
]).then(() => undefined);
|
clearTimeout(timeout);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { loaded, loadedBeforeTimeout };
|
||||||
};
|
};
|
||||||
|
|
||||||
type TerminalSize = { cols: number; rows: number };
|
type TerminalSize = { cols: number; rows: number };
|
||||||
@@ -91,6 +97,7 @@ type Props = {
|
|||||||
onInput: (data: string) => void;
|
onInput: (data: string) => void;
|
||||||
onResize: (cols: number, rows: number) => void;
|
onResize: (cols: number, rows: number) => void;
|
||||||
theme: TerminalTheme;
|
theme: TerminalTheme;
|
||||||
|
monoFont: MonoFontOption;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -100,7 +107,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||||
sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className,
|
sessionKey, chunks, onInput, onResize, theme, monoFont, fontFamily, fontSize, className,
|
||||||
enableTouchScroll = false, autoFocus = true, isVisible = true,
|
enableTouchScroll = false, autoFocus = true, isVisible = true,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -236,7 +243,8 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|||||||
window.addEventListener('focus', handleWindowFocus);
|
window.addEventListener('focus', handleWindowFocus);
|
||||||
window.addEventListener('blur', handleWindowBlur);
|
window.addEventListener('blur', handleWindowBlur);
|
||||||
|
|
||||||
Promise.all([loadGhostty(), ensureNerdFonts()]).then(([{ module, ghostty }]) => {
|
const fonts = waitForTerminalFonts(monoFont);
|
||||||
|
Promise.all([loadGhostty(), fonts.loadedBeforeTimeout]).then(([{ module, ghostty }, fontsLoaded]) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
terminal = new module.Terminal({
|
terminal = new module.Terminal({
|
||||||
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
|
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
|
||||||
@@ -264,6 +272,13 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|||||||
const safeReset = safeResetRef.current;
|
const safeReset = safeResetRef.current;
|
||||||
if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`);
|
if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`);
|
||||||
fitFrame = requestAnimationFrame(fit);
|
fitFrame = requestAnimationFrame(fit);
|
||||||
|
if (!fontsLoaded) {
|
||||||
|
void fonts.loaded.then(() => {
|
||||||
|
if (!disposed && terminalRef.current === terminal) {
|
||||||
|
setRendererGeneration((value) => value + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -301,7 +316,7 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|||||||
writeEpochRef.current += 1;
|
writeEpochRef.current += 1;
|
||||||
rendererReadyRef.current = false;
|
rendererReadyRef.current = false;
|
||||||
};
|
};
|
||||||
}, [fit, fontFamily, fontSize, rendererGeneration, theme]);
|
}, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const terminal = terminalRef.current;
|
const terminal = terminalRef.current;
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ type ContentProps = React.ComponentProps<typeof BaseTooltip.Popup> & {
|
|||||||
sideOffset?: number;
|
sideOffset?: number;
|
||||||
side?: "top" | "right" | "bottom" | "left";
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
align?: "start" | "center" | "end";
|
align?: "start" | "center" | "end";
|
||||||
|
showArrow?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function TooltipContent({
|
function TooltipContent({
|
||||||
@@ -265,6 +266,7 @@ function TooltipContent({
|
|||||||
align,
|
align,
|
||||||
children,
|
children,
|
||||||
style,
|
style,
|
||||||
|
showArrow = false,
|
||||||
...props
|
...props
|
||||||
}: ContentProps) {
|
}: ContentProps) {
|
||||||
return (
|
return (
|
||||||
@@ -277,14 +279,17 @@ function TooltipContent({
|
|||||||
// data-instant is set when moving between grouped tooltips
|
// data-instant is set when moving between grouped tooltips
|
||||||
// (shared TooltipProvider): reposition without replaying the
|
// (shared TooltipProvider): reposition without replaying the
|
||||||
// full exit/enter animation.
|
// full exit/enter animation.
|
||||||
"oc-glass-tooltip text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[instant]:transition-none data-[instant]:duration-0 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance overflow-hidden",
|
"oc-glass-tooltip text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[instant]:transition-none data-[instant]:duration-0 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance",
|
||||||
|
showArrow ? "overflow-visible" : "overflow-hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ ...style }}
|
style={{ ...style }}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<BaseTooltip.Arrow className="fill-[var(--surface-elevated)] z-50 size-2" />
|
{showArrow ? (
|
||||||
|
<BaseTooltip.Arrow className="relative z-50 block h-1.5 w-3 overflow-clip data-[side=bottom]:top-[-6px] data-[side=left]:right-[-9px] data-[side=left]:rotate-90 data-[side=right]:left-[-9px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-6px] data-[side=top]:rotate-180 before:absolute before:bottom-0 before:left-1/2 before:block before:h-[calc(6px*sqrt(2))] before:w-[calc(6px*sqrt(2))] before:border before:border-border/60 before:bg-[var(--surface-elevated)] before:content-[''] before:[transform:translate(-50%,50%)_rotate(45deg)]" />
|
||||||
|
) : null}
|
||||||
</BaseTooltip.Popup>
|
</BaseTooltip.Popup>
|
||||||
</BaseTooltip.Positioner>
|
</BaseTooltip.Positioner>
|
||||||
</BaseTooltip.Portal>
|
</BaseTooltip.Portal>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { GitEmptyState } from './git/GitEmptyState';
|
|||||||
import { HistorySection } from './git/HistorySection';
|
import { HistorySection } from './git/HistorySection';
|
||||||
import { ConflictDialog } from './git/ConflictDialog';
|
import { ConflictDialog } from './git/ConflictDialog';
|
||||||
import { StashDialog } from './git/StashDialog';
|
import { StashDialog } from './git/StashDialog';
|
||||||
|
import { DirtyBranchSwitchDialog } from './git/DirtyBranchSwitchDialog';
|
||||||
import { InProgressOperationBanner } from './git/InProgressOperationBanner';
|
import { InProgressOperationBanner } from './git/InProgressOperationBanner';
|
||||||
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
|
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
|
||||||
import { deriveBaseBranch } from './git/baseBranch';
|
import { deriveBaseBranch } from './git/baseBranch';
|
||||||
@@ -706,6 +707,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
|||||||
}
|
}
|
||||||
}, [conflictStorageKey, gitDirectory]);
|
}, [conflictStorageKey, gitDirectory]);
|
||||||
const [stashDialogOpen, setStashDialogOpen] = React.useState(false);
|
const [stashDialogOpen, setStashDialogOpen] = React.useState(false);
|
||||||
|
// Branch a dirty-tree switch is waiting on; null when no switch is blocked.
|
||||||
|
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null);
|
||||||
const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge');
|
const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge');
|
||||||
const [stashDialogBranch, setStashDialogBranch] = React.useState('');
|
const [stashDialogBranch, setStashDialogBranch] = React.useState('');
|
||||||
|
|
||||||
@@ -1371,6 +1374,21 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A checkout over uncommitted changes can carry them onto the target
|
||||||
|
// branch, conflict, or silently rewrite what the user was editing. The
|
||||||
|
// switch is blocked until the working tree is resolved: commit, or
|
||||||
|
// explicitly revert (DirtyBranchSwitchDialog).
|
||||||
|
if ((status?.files?.length ?? 0) > 0) {
|
||||||
|
setPendingDirtySwitchBranch(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await performCheckout(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const performCheckout = async (branch: string) => {
|
||||||
|
if (!gitDirectory) return;
|
||||||
|
const normalized = branch;
|
||||||
try {
|
try {
|
||||||
// Picking a remote-tracking branch checks out the local branch that
|
// Picking a remote-tracking branch checks out the local branch that
|
||||||
// tracks it, so report the branch the repository actually landed on.
|
// tracks it, so report the branch the repository actually landed on.
|
||||||
@@ -2369,7 +2387,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full flex-col overflow-hidden')}>
|
<div className={cn('flex h-full flex-col overflow-hidden')}>
|
||||||
<GitHeader
|
<GitHeader
|
||||||
|
directory={gitDirectory ?? ''}
|
||||||
status={status}
|
status={status}
|
||||||
localBranches={localBranches}
|
localBranches={localBranches}
|
||||||
remoteBranches={remoteBranches}
|
remoteBranches={remoteBranches}
|
||||||
@@ -2670,6 +2689,83 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DirtyBranchSwitchDialog
|
||||||
|
open={pendingDirtySwitchBranch !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setPendingDirtySwitchBranch(null); }}
|
||||||
|
targetBranch={pendingDirtySwitchBranch ?? ''}
|
||||||
|
changedFileCount={status?.files?.length ?? 0}
|
||||||
|
onCommitAndSwitch={async (message, pushAfter) => {
|
||||||
|
const branch = pendingDirtySwitchBranch;
|
||||||
|
if (!branch || !gitDirectory) return;
|
||||||
|
const sourceBranch = status?.current ?? null;
|
||||||
|
await git.createGitCommit(gitDirectory, message, { addAll: true });
|
||||||
|
bumpIndexRevision(gitDirectory);
|
||||||
|
let pushedRemoteName: string | null = null;
|
||||||
|
if (pushAfter) {
|
||||||
|
const trackingRemoteName = status?.tracking?.split('/')[0];
|
||||||
|
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
|
||||||
|
try {
|
||||||
|
if (!remote) throw new Error(t('mobile.changes.noRemote'));
|
||||||
|
await git.gitPush(gitDirectory, status?.tracking
|
||||||
|
? { remote: remote.name }
|
||||||
|
: { remote: remote.name, branch: sourceBranch ?? undefined, options: ['--set-upstream'] });
|
||||||
|
pushedRemoteName = remote.name;
|
||||||
|
} catch (error) {
|
||||||
|
// The commit stands, so nothing is lost — but the switch is
|
||||||
|
// cancelled: the user must see the failed push on the branch it
|
||||||
|
// belongs to instead of discovering it later from elsewhere.
|
||||||
|
console.error('Push after commit failed:', error);
|
||||||
|
toast.error(t('gitView.dirtySwitch.pushFailed'));
|
||||||
|
await refreshStatusAndBranches();
|
||||||
|
await refreshLog();
|
||||||
|
setPendingDirtySwitchBranch(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Without a push the commit stays local on the branch being left;
|
||||||
|
// after the switch nothing on screen would say so, so the toast must.
|
||||||
|
toast.success(pushedRemoteName
|
||||||
|
? t('gitView.toast.pushedToUpstream', { name: pushedRemoteName })
|
||||||
|
: sourceBranch
|
||||||
|
? t('gitView.dirtySwitch.committedNotPushed', { branch: sourceBranch })
|
||||||
|
: t('gitView.toast.commitCreated'));
|
||||||
|
await refreshStatusAndBranches();
|
||||||
|
await refreshLog();
|
||||||
|
setPendingDirtySwitchBranch(null);
|
||||||
|
await performCheckout(branch);
|
||||||
|
}}
|
||||||
|
onGenerateMessage={async () => {
|
||||||
|
if (!gitDirectory) return '';
|
||||||
|
const paths = (status?.files ?? []).map((file) => file.path).sort();
|
||||||
|
const { message } = await generateSessionCommitMessage(gitDirectory, paths);
|
||||||
|
const subject = message.subject?.trim() ?? '';
|
||||||
|
// Same gitmoji decoration as the commit panel's Generate button.
|
||||||
|
if (subject && settingsGitmojiEnabled && gitmojiEmojis.length > 0) {
|
||||||
|
const match = matchGitmojiFromSubject(subject, gitmojiEmojis);
|
||||||
|
if (match && !subject.startsWith(match.code) && !subject.startsWith(match.emoji)) {
|
||||||
|
return `${match.code} ${subject}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subject;
|
||||||
|
}}
|
||||||
|
onRevertAndSwitch={async () => {
|
||||||
|
const branch = pendingDirtySwitchBranch;
|
||||||
|
if (!branch || !gitDirectory) return;
|
||||||
|
const paths = (status?.files ?? []).map((file) => file.path);
|
||||||
|
await handleRevertPaths(paths, true, 'all');
|
||||||
|
// The revert reports its own partial failures; the checkout happens
|
||||||
|
// only once the tree is verifiably clean, so a half-reverted tree is
|
||||||
|
// never switched over.
|
||||||
|
const fresh = await git.getGitStatus(gitDirectory);
|
||||||
|
if (!fresh.isClean && (fresh.files?.length ?? 0) > 0) {
|
||||||
|
toast.error(t('gitView.dirtySwitch.revertIncomplete'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingDirtySwitchBranch(null);
|
||||||
|
await performCheckout(branch);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<StashDialog
|
<StashDialog
|
||||||
open={stashDialogOpen}
|
open={stashDialogOpen}
|
||||||
onOpenChange={setStashDialogOpen}
|
onOpenChange={setStashDialogOpen}
|
||||||
|
|||||||
@@ -251,6 +251,14 @@ export const LinearIssuesView: React.FC = () => {
|
|||||||
const setListPriority = useUIStore((state) => state.setLinearIssueListPriority);
|
const setListPriority = useUIStore((state) => state.setLinearIssueListPriority);
|
||||||
const resetListFilters = useUIStore((state) => state.resetLinearIssueListFilters);
|
const resetListFilters = useUIStore((state) => state.resetLinearIssueListFilters);
|
||||||
const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus);
|
const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus);
|
||||||
|
const applyLinearFiltersForRuntime = useUIStore((state) => state.applyLinearIssueListFiltersForRuntime);
|
||||||
|
|
||||||
|
// The team filter is stored per instance, and rehydration can run before the
|
||||||
|
// runtime endpoint is known. Reading it here means the view always opens on
|
||||||
|
// the filter belonging to the instance it is about to query.
|
||||||
|
React.useEffect(() => {
|
||||||
|
applyLinearFiltersForRuntime();
|
||||||
|
}, [applyLinearFiltersForRuntime]);
|
||||||
|
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
const [searchOpen, setSearchOpen] = React.useState(false);
|
const [searchOpen, setSearchOpen] = React.useState(false);
|
||||||
|
|||||||
@@ -1140,6 +1140,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
onInput={handleViewportInput}
|
onInput={handleViewportInput}
|
||||||
onResize={handleViewportResize}
|
onResize={handleViewportResize}
|
||||||
theme={xtermTheme}
|
theme={xtermTheme}
|
||||||
|
monoFont={monoFont}
|
||||||
fontFamily={resolvedFontStack}
|
fontFamily={resolvedFontStack}
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
enableTouchScroll={useTouchTerminalInput}
|
enableTouchScroll={useTouchTerminalInput}
|
||||||
|
|||||||
@@ -100,4 +100,13 @@ describe('terminal viewport remount guard', () => {
|
|||||||
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
|
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
|
||||||
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
|
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rebuilds the canvas renderer when terminal fonts finish loading after the startup bound', () => {
|
||||||
|
expect(terminalViewportSource).toContain('loadMonoFont(font)');
|
||||||
|
expect(terminalViewportSource).toContain('Promise.all([loadMonoFont(font), loadNerdFonts()])');
|
||||||
|
expect(terminalViewportSource).toContain('Promise.all([loadGhostty(), fonts.loadedBeforeTimeout])');
|
||||||
|
expect(terminalViewportSource).toContain('if (!fontsLoaded)');
|
||||||
|
expect(terminalViewportSource).toContain('void fonts.loaded.then(() => {');
|
||||||
|
expect(terminalViewportSource).toContain('setRendererGeneration((value) => value + 1)');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ import {
|
|||||||
} from '@/components/ui/command';
|
} from '@/components/ui/command';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
import type { GitRemote } from '@/lib/api/types';
|
import type { GitRemote } from '@/lib/api/types';
|
||||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
|
import { getGitUnpushedBranchCounts } from '@/lib/gitApi';
|
||||||
|
import { getRecentBranches, rememberRecentBranch } from './recentBranches';
|
||||||
|
|
||||||
interface BranchInfo {
|
interface BranchInfo {
|
||||||
ahead?: number;
|
ahead?: number;
|
||||||
@@ -30,10 +34,18 @@ interface BranchSelectorProps {
|
|||||||
localBranches: string[];
|
localBranches: string[];
|
||||||
remoteBranches: string[];
|
remoteBranches: string[];
|
||||||
branchInfo: Record<string, BranchInfo> | undefined;
|
branchInfo: Record<string, BranchInfo> | undefined;
|
||||||
|
currentBranchAhead?: number;
|
||||||
onCheckout: (branch: string) => void;
|
onCheckout: (branch: string) => void;
|
||||||
onCreate: (name: string, remote?: GitRemote) => Promise<void>;
|
onCreate: (name: string, remote?: GitRemote) => Promise<void>;
|
||||||
remotes?: GitRemote[];
|
remotes?: GitRemote[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
directory: string;
|
||||||
|
/**
|
||||||
|
* Shown above the branch list while the working tree has uncommitted
|
||||||
|
* changes: selecting a branch will not switch directly but opens the
|
||||||
|
* commit-or-revert resolution instead.
|
||||||
|
*/
|
||||||
|
switchBlockedNotice?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizeBranchNameInput = (value: string): string => {
|
const sanitizeBranchNameInput = (value: string): string => {
|
||||||
@@ -54,18 +66,24 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
localBranches,
|
localBranches,
|
||||||
remoteBranches,
|
remoteBranches,
|
||||||
branchInfo,
|
branchInfo,
|
||||||
|
currentBranchAhead = 0,
|
||||||
onCheckout,
|
onCheckout,
|
||||||
onCreate,
|
onCreate,
|
||||||
remotes = [],
|
remotes = [],
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
directory,
|
||||||
|
switchBlockedNotice = null,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { isMobile } = useDeviceInfo();
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
const [showCreate, setShowCreate] = React.useState(false);
|
const [showCreate, setShowCreate] = React.useState(false);
|
||||||
const [showRemoteSelect, setShowRemoteSelect] = React.useState(false);
|
const [showRemoteSelect, setShowRemoteSelect] = React.useState(false);
|
||||||
const [newBranchName, setNewBranchName] = React.useState('');
|
const [newBranchName, setNewBranchName] = React.useState('');
|
||||||
const [isCreating, setIsCreating] = React.useState(false);
|
const [isCreating, setIsCreating] = React.useState(false);
|
||||||
|
const [recentBranches, setRecentBranches] = React.useState<string[]>(() => getRecentBranches(directory));
|
||||||
|
const [unpushedCounts, setUnpushedCounts] = React.useState<Record<string, number>>({});
|
||||||
const createInputRef = React.useRef<HTMLInputElement>(null);
|
const createInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
@@ -94,6 +112,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setRecentBranches(rememberRecentBranch(directory, branch));
|
||||||
onCheckout(branch);
|
onCheckout(branch);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
@@ -158,6 +177,109 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!directory) return;
|
||||||
|
setRecentBranches(currentBranch
|
||||||
|
? rememberRecentBranch(directory, currentBranch)
|
||||||
|
: getRecentBranches(directory));
|
||||||
|
}, [currentBranch, directory]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const branches = recentBranches.filter((branch) => localBranches.includes(branch)).slice(0, 5);
|
||||||
|
if (branches.length === 0) return setUnpushedCounts({});
|
||||||
|
let cancelled = false;
|
||||||
|
getGitUnpushedBranchCounts(directory, branches)
|
||||||
|
.then(({ counts }) => { if (!cancelled) setUnpushedCounts(counts); })
|
||||||
|
.catch(() => { if (!cancelled) setUnpushedCounts({}); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [directory, isOpen, localBranches, recentBranches]);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
const recentLocalBranches = recentBranches.filter((branch) => localBranches.includes(branch));
|
||||||
|
const renderBranch = (branch: string, remote = false) => {
|
||||||
|
const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0);
|
||||||
|
const aheadLabel = ahead === 1
|
||||||
|
? t('gitView.branch.unpushedSingle')
|
||||||
|
: t('gitView.branch.unpushedPlural', { count: ahead });
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${remote ? 'remote' : 'local'}-${branch}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCheckout(branch)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-2 py-2.5 text-left typography-ui-label hover:bg-interactive-hover"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{branch}</span>
|
||||||
|
{ahead > 0 ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 typography-micro text-muted-foreground"
|
||||||
|
title={aheadLabel}
|
||||||
|
aria-label={aheadLabel}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-up" className="size-3" aria-hidden="true" />
|
||||||
|
<span aria-hidden="true">{ahead}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{currentBranch === branch ? <Icon name="check" className="size-4 shrink-0 text-primary" /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 min-w-0 max-w-full justify-start gap-1.5 px-2 py-1"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
>
|
||||||
|
<Icon name="git-branch" className="size-4 text-primary" />
|
||||||
|
<span className="min-w-0 truncate font-medium text-left">
|
||||||
|
{currentBranch || t('gitView.branch.detachedHead')}
|
||||||
|
</span>
|
||||||
|
<Icon name="arrow-down-s" className="size-4 opacity-60" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<MobileOverlayPanel
|
||||||
|
open={isOpen}
|
||||||
|
title={t('gitView.branch.currentBranchTooltip')}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 px-3 pb-4 pt-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||||
|
className="h-9 w-full rounded-lg border border-border bg-transparent px-3 typography-meta outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
/>
|
||||||
|
{switchBlockedNotice ? (
|
||||||
|
<div className="flex items-start gap-2 px-2 py-1">
|
||||||
|
<Icon name="alert" className="mt-0.5 size-3.5 shrink-0 text-[var(--status-warning)]" aria-hidden="true" />
|
||||||
|
<span className="typography-micro text-muted-foreground">{switchBlockedNotice}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{recentLocalBranches.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.recentBranches')}</p>
|
||||||
|
{recentLocalBranches.map((branch) => renderBranch(branch))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section>
|
||||||
|
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.localBranches')}</p>
|
||||||
|
{filteredLocal.map((branch) => renderBranch(branch))}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.remoteBranches')}</p>
|
||||||
|
{filteredRemote.map((branch) => renderBranch(branch, true))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</MobileOverlayPanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -192,6 +314,12 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
onKeyDown={stopDropdownTypeahead}
|
onKeyDown={stopDropdownTypeahead}
|
||||||
/>
|
/>
|
||||||
|
{switchBlockedNotice ? (
|
||||||
|
<div className="flex items-start gap-2 border-b border-border/60 px-3 py-2">
|
||||||
|
<Icon name="alert" className="mt-0.5 size-3.5 shrink-0 text-[var(--status-warning)]" aria-hidden="true" />
|
||||||
|
<span className="typography-micro text-muted-foreground">{switchBlockedNotice}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<CommandList
|
<CommandList
|
||||||
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
|
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
|
||||||
disableHorizontal
|
disableHorizontal
|
||||||
@@ -288,6 +416,38 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
|
|
||||||
<CommandSeparator />
|
<CommandSeparator />
|
||||||
|
|
||||||
|
{recentBranches.filter((branch) => localBranches.includes(branch)).length > 0 ? (
|
||||||
|
<>
|
||||||
|
<CommandGroup heading={t('gitView.branch.recentBranches')}>
|
||||||
|
{recentBranches.filter((branch) => localBranches.includes(branch)).map((branch) => (
|
||||||
|
<CommandItem key={`recent-${branch}`} onSelect={() => handleCheckout(branch)}>
|
||||||
|
<span className="flex flex-1 items-center gap-2 min-w-0">
|
||||||
|
<span className="typography-ui-label text-foreground truncate">{branch}</span>
|
||||||
|
{(() => {
|
||||||
|
const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0);
|
||||||
|
const aheadLabel = ahead === 1
|
||||||
|
? t('gitView.branch.unpushedSingle')
|
||||||
|
: t('gitView.branch.unpushedPlural', { count: ahead });
|
||||||
|
return ahead > 0 ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 typography-micro text-muted-foreground"
|
||||||
|
title={aheadLabel}
|
||||||
|
aria-label={aheadLabel}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-up" className="size-3" aria-hidden="true" />
|
||||||
|
<span aria-hidden="true">{ahead}</span>
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
{currentBranch === branch ? <span className="typography-micro text-primary">{t('gitView.branch.currentBadge')}</span> : null}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<CommandGroup heading={t('gitView.branch.localBranches')}>
|
<CommandGroup heading={t('gitView.branch.localBranches')}>
|
||||||
{filteredLocal.map((branch) => (
|
{filteredLocal.map((branch) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { toast } from '@/components/ui';
|
||||||
|
import { Icon } from '@/components/icon/Icon';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface DirtyBranchSwitchDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
targetBranch: string;
|
||||||
|
changedFileCount: number;
|
||||||
|
/**
|
||||||
|
* Commit every uncommitted change with this message — pushing the commit
|
||||||
|
* first when the user opted in — then perform the checkout.
|
||||||
|
*/
|
||||||
|
onCommitAndSwitch: (message: string, pushAfter: boolean) => Promise<void>;
|
||||||
|
/** Produce an AI commit message for the current changes, same as the commit panel. */
|
||||||
|
onGenerateMessage: () => Promise<string>;
|
||||||
|
/** Revert every uncommitted change, then perform the checkout. */
|
||||||
|
onRevertAndSwitch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switching branches with uncommitted changes is blocked so a checkout can
|
||||||
|
* never silently carry, conflict with, or drop the user's work. The user
|
||||||
|
* resolves the working tree with one explicit choice: commit and switch
|
||||||
|
* (message written, generated on demand, or generated automatically when the
|
||||||
|
* field is left empty — same pipeline as the commit panel), or revert and
|
||||||
|
* switch. Cancel leaves everything untouched for a fully manual flow.
|
||||||
|
*/
|
||||||
|
export const DirtyBranchSwitchDialog: React.FC<DirtyBranchSwitchDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
targetBranch,
|
||||||
|
changedFileCount,
|
||||||
|
onCommitAndSwitch,
|
||||||
|
onGenerateMessage,
|
||||||
|
onRevertAndSwitch,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [commitMessage, setCommitMessage] = React.useState('');
|
||||||
|
const [pendingAction, setPendingAction] = React.useState<'generate' | 'commit' | 'revert' | null>(null);
|
||||||
|
const [pushAfter, setPushAfter] = React.useState(false);
|
||||||
|
const isProcessing = pendingAction !== null;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setCommitMessage('');
|
||||||
|
setPushAfter(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setPendingAction('generate');
|
||||||
|
try {
|
||||||
|
const generated = await onGenerateMessage();
|
||||||
|
if (generated) setCommitMessage(generated);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
|
||||||
|
} finally {
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// An empty field is not an obstacle: the message is generated on the spot,
|
||||||
|
// through the same pipeline as the commit panel, and the commit proceeds.
|
||||||
|
const handleCommitAndSwitch = async () => {
|
||||||
|
setPendingAction('commit');
|
||||||
|
try {
|
||||||
|
let message = commitMessage.trim();
|
||||||
|
if (!message) {
|
||||||
|
message = (await onGenerateMessage()).trim();
|
||||||
|
if (!message) {
|
||||||
|
toast.error(t('gitView.toast.enterCommitMessage'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCommitMessage(message);
|
||||||
|
}
|
||||||
|
await onCommitAndSwitch(message, pushAfter);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
|
||||||
|
} finally {
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevertAndSwitch = async () => {
|
||||||
|
setPendingAction('revert');
|
||||||
|
try {
|
||||||
|
await onRevertAndSwitch();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed'));
|
||||||
|
} finally {
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => { if (!isProcessing) onOpenChange(next); }}>
|
||||||
|
<DialogContent className="max-w-md w-[calc(100vw-2rem)]">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon name="alert" className="size-5 shrink-0 text-[var(--status-warning)]" />
|
||||||
|
<DialogTitle>{t('gitView.dirtySwitch.title')}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription>
|
||||||
|
{changedFileCount === 1
|
||||||
|
? t('gitView.dirtySwitch.descriptionSingle', { branch: targetBranch })
|
||||||
|
: t('gitView.dirtySwitch.descriptionPlural', { branch: targetBranch, count: changedFileCount })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-border/60 px-3 py-2 focus-within:border-border">
|
||||||
|
<input
|
||||||
|
value={commitMessage}
|
||||||
|
onChange={(event) => setCommitMessage(event.target.value)}
|
||||||
|
placeholder={t('gitView.commit.messagePlaceholder')}
|
||||||
|
disabled={isProcessing}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !isProcessing) {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleCommitAndSwitch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="min-w-0 flex-1 bg-transparent typography-meta text-foreground outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void handleGenerate(); }}
|
||||||
|
disabled={isProcessing}
|
||||||
|
aria-label={t('gitView.commit.generate')}
|
||||||
|
title={t('gitView.commit.generate')}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pendingAction === 'generate' ? (
|
||||||
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Icon name="ai-generate-2" className="size-4 text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={pushAfter}
|
||||||
|
onChange={setPushAfter}
|
||||||
|
disabled={isProcessing}
|
||||||
|
ariaLabel={t('gitView.dirtySwitch.pushAfterCommit')}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="typography-ui-label text-foreground cursor-pointer select-none"
|
||||||
|
onClick={() => !isProcessing && setPushAfter(!pushAfter)}
|
||||||
|
>
|
||||||
|
{t('gitView.dirtySwitch.pushAfterCommit')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { void handleRevertAndSwitch(); }}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{pendingAction === 'revert' ? (
|
||||||
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{t('gitView.dirtySwitch.revertAndSwitch')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { void handleCommitAndSwitch(); }}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{pendingAction === 'commit' ? (
|
||||||
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{t('gitView.dirtySwitch.commitAndSwitch')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,10 +22,12 @@ import type {
|
|||||||
GitHubChecksSummary,
|
GitHubChecksSummary,
|
||||||
} from '@/lib/api/types';
|
} from '@/lib/api/types';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
|
|
||||||
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
||||||
|
|
||||||
interface GitHeaderProps {
|
interface GitHeaderProps {
|
||||||
|
directory: string;
|
||||||
status: GitStatus | null;
|
status: GitStatus | null;
|
||||||
localBranches: string[];
|
localBranches: string[];
|
||||||
remoteBranches: string[];
|
remoteBranches: string[];
|
||||||
@@ -240,6 +242,7 @@ const UpstreamStatusPill: React.FC<UpstreamStatusPillProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const GitHeader: React.FC<GitHeaderProps> = ({
|
export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||||
|
directory,
|
||||||
status,
|
status,
|
||||||
localBranches,
|
localBranches,
|
||||||
remoteBranches,
|
remoteBranches,
|
||||||
@@ -272,6 +275,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { isMobile } = useDeviceInfo();
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -425,20 +429,23 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
<header className="@container/git-header px-3 py-2 bg-transparent">
|
<header className="@container/git-header px-3 py-2 bg-transparent">
|
||||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||||
{isWorktreeMode ? (
|
{isWorktreeMode && !isMobile ? (
|
||||||
<WorktreeBranchDisplay
|
<WorktreeBranchDisplay
|
||||||
currentBranch={status.current}
|
currentBranch={status.current}
|
||||||
onRename={onRenameBranch}
|
onRename={onRenameBranch}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BranchSelector
|
<BranchSelector
|
||||||
|
directory={directory}
|
||||||
currentBranch={status.current}
|
currentBranch={status.current}
|
||||||
localBranches={localBranches}
|
localBranches={localBranches}
|
||||||
remoteBranches={remoteBranches}
|
remoteBranches={remoteBranches}
|
||||||
branchInfo={branchInfo}
|
branchInfo={branchInfo}
|
||||||
|
currentBranchAhead={status.ahead}
|
||||||
onCheckout={onCheckoutBranch}
|
onCheckout={onCheckoutBranch}
|
||||||
onCreate={onCreateBranch}
|
onCreate={onCreateBranch}
|
||||||
remotes={remotes}
|
remotes={remotes}
|
||||||
|
switchBlockedNotice={(status.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{repositoryOptionsForPicker.length > 0 && onSelectRepository ? (
|
{repositoryOptionsForPicker.length > 0 && onSelectRepository ? (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||||
|
import { getRecentBranches, rememberRecentBranch } from './recentBranches';
|
||||||
|
|
||||||
|
class TestStorage implements Storage {
|
||||||
|
#values = new Map<string, string>();
|
||||||
|
|
||||||
|
get length(): number { return this.#values.size; }
|
||||||
|
clear(): void { this.#values.clear(); }
|
||||||
|
getItem(key: string): string | null { return this.#values.get(key) ?? null; }
|
||||||
|
key(index: number): string | null { return [...this.#values.keys()][index] ?? null; }
|
||||||
|
removeItem(key: string): void { this.#values.delete(key); }
|
||||||
|
setItem(key: string, value: string): void { this.#values.set(key, value); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLocalStorage = globalThis.localStorage;
|
||||||
|
let storage: TestStorage;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new TestStorage();
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: originalLocalStorage });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recent Git branches', () => {
|
||||||
|
test('persists a branch list for a later UI mount', () => {
|
||||||
|
rememberRecentBranch('/repo', 'feature/one');
|
||||||
|
rememberRecentBranch('/repo', 'feature/two');
|
||||||
|
|
||||||
|
expect(getRecentBranches('/repo')).toEqual(['feature/two', 'feature/one']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps only the five most recently used branches', () => {
|
||||||
|
for (let index = 1; index <= 6; index += 1) {
|
||||||
|
rememberRecentBranch('/repo', `feature/${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getRecentBranches('/repo')).toEqual([
|
||||||
|
'feature/6', 'feature/5', 'feature/4', 'feature/3', 'feature/2',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { normalizePath } from '@/lib/pathNormalization';
|
||||||
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const KEY = 'openchamber:recent-git-branches:v1';
|
||||||
|
const LIMIT = 5;
|
||||||
|
|
||||||
|
const entriesSchema = z.record(z.string(), z.array(z.string()));
|
||||||
|
type Entries = z.infer<typeof entriesSchema>;
|
||||||
|
|
||||||
|
const keyFor = (directory: string): string | null => {
|
||||||
|
const normalized = normalizePath(directory);
|
||||||
|
return normalized ? `${getRuntimeKey()}:${normalized}` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const read = (): Entries => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
const parsed = entriesSchema.safeParse(raw ? JSON.parse(raw) : null);
|
||||||
|
return parsed.success
|
||||||
|
? Object.fromEntries(Object.entries(parsed.data).map(([key, branches]) => [key, branches.slice(0, LIMIT)]))
|
||||||
|
: {};
|
||||||
|
} catch { return {}; }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRecentBranches = (directory: string): string[] => {
|
||||||
|
const key = keyFor(directory);
|
||||||
|
return key ? read()[key] ?? [] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rememberRecentBranch = (directory: string, branch: string): string[] => {
|
||||||
|
const key = keyFor(directory);
|
||||||
|
if (!key || !branch) return [];
|
||||||
|
const entries = read();
|
||||||
|
const next = [branch, ...(entries[key] ?? []).filter((item) => item !== branch)].slice(0, LIMIT);
|
||||||
|
try { localStorage.setItem(KEY, JSON.stringify({ ...entries, [key]: next })); } catch { /* convenience only */ }
|
||||||
|
return next;
|
||||||
|
};
|
||||||
@@ -295,12 +295,12 @@ describe('settings sync resolution', () => {
|
|||||||
const serverTheme = { useSystemTheme: false as const, themeVariant: 'dark' as const, lightThemeId: 'server-light', darkThemeId: 'server-dark' };
|
const serverTheme = { useSystemTheme: false as const, themeVariant: 'dark' as const, lightThemeId: 'server-light', darkThemeId: 'server-dark' };
|
||||||
|
|
||||||
test('a non-bootstrap sync (settings save echo) never changes preferences', () => {
|
test('a non-bootstrap sync (settings save echo) never changes preferences', () => {
|
||||||
expect(resolveThemePreferencesFromSettingsSync({ bootstrap: false, settings: serverTheme }, current)).toBeNull();
|
expect(resolveThemePreferencesFromSettingsSync({ adoptTheme: false, settings: serverTheme }, current)).toBeNull();
|
||||||
expect(resolveThemePreferencesFromSettingsSync(null, current)).toBeNull();
|
expect(resolveThemePreferencesFromSettingsSync(null, current)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a bootstrap sync adopts the server theme', () => {
|
test('a bootstrap sync adopts the server theme', () => {
|
||||||
expect(resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: serverTheme }, current)).toEqual({
|
expect(resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: serverTheme }, current)).toEqual({
|
||||||
themeMode: 'dark',
|
themeMode: 'dark',
|
||||||
lightThemeId: 'server-light',
|
lightThemeId: 'server-light',
|
||||||
darkThemeId: 'server-dark',
|
darkThemeId: 'server-dark',
|
||||||
@@ -308,19 +308,19 @@ describe('settings sync resolution', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('theme fields omitted by the server keep the current preferences (not-set is not reset-to-defaults)', () => {
|
test('theme fields omitted by the server keep the current preferences (not-set is not reset-to-defaults)', () => {
|
||||||
expect(resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: {} }, current)).toBeNull();
|
expect(resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: {} }, current)).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: { useSystemTheme: true } }, current),
|
resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: { useSystemTheme: true } }, current),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: { lightThemeId: 'server-light' } }, current),
|
resolveThemePreferencesFromSettingsSync({ adoptTheme: true, settings: { lightThemeId: 'server-light' } }, current),
|
||||||
).toEqual({ themeMode: 'system', lightThemeId: 'server-light', darkThemeId: 'dark-theme' });
|
).toEqual({ themeMode: 'system', lightThemeId: 'server-light', darkThemeId: 'dark-theme' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a bootstrap sync carrying the current preferences resolves to no change', () => {
|
test('a bootstrap sync carrying the current preferences resolves to no change', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveThemePreferencesFromSettingsSync(
|
resolveThemePreferencesFromSettingsSync(
|
||||||
{ bootstrap: true, settings: { useSystemTheme: true, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' } },
|
{ adoptTheme: true, settings: { useSystemTheme: true, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' } },
|
||||||
current,
|
current,
|
||||||
),
|
),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|||||||
@@ -201,10 +201,10 @@ type SettingsSyncThemePayload = Pick<
|
|||||||
* theme lookup downstream and falls back cosmetically.
|
* theme lookup downstream and falls back cosmetically.
|
||||||
*/
|
*/
|
||||||
export const resolveThemePreferencesFromSettingsSync = (
|
export const resolveThemePreferencesFromSettingsSync = (
|
||||||
detail: { bootstrap: boolean; settings: SettingsSyncThemePayload } | null,
|
detail: { adoptTheme: boolean; settings: SettingsSyncThemePayload } | null,
|
||||||
current: StoredThemePreferences,
|
current: StoredThemePreferences,
|
||||||
): StoredThemePreferences | null => {
|
): StoredThemePreferences | null => {
|
||||||
if (!detail?.bootstrap) {
|
if (!detail?.adoptTheme) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./useProviderLogo.ts', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
describe('provider logo aliases', () => {
|
||||||
|
test('maps rotating exe.dev proxy provider IDs to the local exe.dev logo', () => {
|
||||||
|
expect(source).toContain("compact.startsWith('exe-') ? 'exe-dev' : undefined");
|
||||||
|
expect(source).toContain('const candidates = [prefixAlias,');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,7 +45,8 @@ const buildLogoCandidates = (providerId: string | null | undefined) => {
|
|||||||
|
|
||||||
const compact = normalized.replace(/[^a-z0-9_\-./:]/g, '');
|
const compact = normalized.replace(/[^a-z0-9_\-./:]/g, '');
|
||||||
const primary = compact.split(/[/:]/)[0] || compact;
|
const primary = compact.split(/[/:]/)[0] || compact;
|
||||||
const candidates = [LOGO_ALIAS.get(compact), LOGO_ALIAS.get(primary), compact, primary]
|
const prefixAlias = compact.startsWith('exe-') ? 'exe-dev' : undefined;
|
||||||
|
const candidates = [prefixAlias, LOGO_ALIAS.get(compact), LOGO_ALIAS.get(primary), compact, primary]
|
||||||
.filter((value): value is string => Boolean(value && value.length > 0));
|
.filter((value): value is string => Boolean(value && value.length > 0));
|
||||||
|
|
||||||
return [...new Set(candidates)];
|
return [...new Set(candidates)];
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ export interface GitStatus {
|
|||||||
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
|
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 {
|
export interface GitDiffResponse {
|
||||||
diff: string;
|
diff: string;
|
||||||
}
|
}
|
||||||
@@ -505,6 +510,7 @@ export interface GitAPI {
|
|||||||
revertGitHunk?(directory: string, filePath: string, patch: string): Promise<void>;
|
revertGitHunk?(directory: string, filePath: string, patch: string): Promise<void>;
|
||||||
isLinkedWorktree(directory: string): Promise<boolean>;
|
isLinkedWorktree(directory: string): Promise<boolean>;
|
||||||
getGitBranches(directory: string): Promise<GitBranch>;
|
getGitBranches(directory: string): Promise<GitBranch>;
|
||||||
|
getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise<GitUnpushedBranchCounts>;
|
||||||
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
|
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
|
||||||
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
|
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
|
||||||
removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }>;
|
removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }>;
|
||||||
|
|||||||
@@ -2,19 +2,52 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|||||||
|
|
||||||
let apiBaseUrl = 'https://remote.example.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', () => ({
|
mock.module('@/lib/desktopNative', () => ({
|
||||||
invokeDesktopCommand: mock(async () => {
|
invokeDesktopCommand: mock(async (_command: string, args?: DesktopTunnelArgs) => {
|
||||||
|
desktopArgs = args;
|
||||||
if (tunnelResult instanceof Error) throw tunnelResult;
|
if (tunnelResult instanceof Error) throw tunnelResult;
|
||||||
return 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', () => ({
|
mock.module('@/lib/runtime-auth', () => ({
|
||||||
getRuntimeBearerTokenSync: () => 'token',
|
getRuntimeBearerTokenSync: () => 'token',
|
||||||
getRuntimeExtraHeadersSync: () => ({}),
|
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', () => ({
|
mock.module('@/lib/runtime-switch', () => ({
|
||||||
getRuntimeApiBaseUrl: () => apiBaseUrl,
|
getRuntimeApiBaseUrl: () => apiBaseUrl,
|
||||||
|
getRuntimeKey: () => relayActive ? 'host:exe' : `url:${apiBaseUrl}`,
|
||||||
subscribeRuntimeEndpointChanged: () => () => {},
|
subscribeRuntimeEndpointChanged: () => () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -25,23 +58,30 @@ const {
|
|||||||
toDisplayUrl,
|
toDisplayUrl,
|
||||||
} = await import('./devTunnel');
|
} = await import('./devTunnel');
|
||||||
|
|
||||||
const globalScope = globalThis as unknown as { window?: unknown };
|
|
||||||
|
|
||||||
const asDesktop = (value: boolean) => {
|
const asDesktop = (value: boolean) => {
|
||||||
globalScope.window = value
|
Object.defineProperty(globalThis, 'window', {
|
||||||
? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } }
|
configurable: true,
|
||||||
: { location: { href: 'http://127.0.0.1:3901/' } };
|
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', () => {
|
describe('loopback navigations against a remote instance', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiBaseUrl = 'https://remote.example.test';
|
apiBaseUrl = 'https://remote.example.test';
|
||||||
tunnelResult = { localPort: 52418, reused: false };
|
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);
|
asDesktop(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete globalScope.window;
|
Reflect.deleteProperty(globalThis, 'window');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a page reached through a tunnel keeps its other ports on the host', () => {
|
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);
|
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', () => {
|
test('a local instance resolves its own loopback correctly', () => {
|
||||||
apiBaseUrl = 'http://127.0.0.1:3901';
|
apiBaseUrl = 'http://127.0.0.1:3901';
|
||||||
expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false);
|
expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false);
|
||||||
|
|||||||
@@ -10,17 +10,66 @@
|
|||||||
* Everywhere else — local runtime, web, mobile — the URL is already correct and
|
* Everywhere else — local runtime, web, mobile — the URL is already correct and
|
||||||
* is returned untouched.
|
* is returned untouched.
|
||||||
*/
|
*/
|
||||||
import { invokeDesktopCommand } from '@/lib/desktopNative';
|
import { invokeDesktopCommand, listenForDesktopRelayDevTunnels, postDesktopRelayDevTunnelMessage } from '@/lib/desktopNative';
|
||||||
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
|
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync, refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||||
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
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';
|
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. */
|
/** Keyed by `${baseUrl}|${port}`; the shell owns the real lifetime. */
|
||||||
const localPortByTarget = new Map<string, number>();
|
const localPortByTarget = new Map<string, number>();
|
||||||
/** Reverse map, so a tunnel port never leaks into the address bar or storage. */
|
/** Reverse map, so a tunnel port never leaks into the address bar or storage. */
|
||||||
const originByLocalPort = new Map<number, string>();
|
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 => (
|
const isDesktopRuntime = (): boolean => (
|
||||||
typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__)
|
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. */
|
/** Thrown when a remote dev server exists but could not be reached from here. */
|
||||||
export class DevTunnelUnavailableError extends Error {
|
export class DevTunnelUnavailableError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -100,11 +157,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
|
|||||||
const key = `${baseUrl}|${port}`;
|
const key = `${baseUrl}|${port}`;
|
||||||
const cached = localPortByTarget.get(key);
|
const cached = localPortByTarget.get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
rememberOriginalOrigin(url, cached);
|
||||||
originByLocalPort.set(cached, new URL(url).origin);
|
|
||||||
} catch {
|
|
||||||
// Unparseable input never reaches here; nothing to record.
|
|
||||||
}
|
|
||||||
return rewriteToLocalPort(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', {
|
const result = await invokeDesktopCommand<TunnelResult>('desktop_dev_tunnel_open', {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
port,
|
port,
|
||||||
|
relay: isRelayModeActive(),
|
||||||
|
targetKey: getRuntimeKey(),
|
||||||
clientToken: getRuntimeBearerTokenSync(),
|
clientToken: getRuntimeBearerTokenSync(),
|
||||||
requestHeaders: getRuntimeExtraHeadersSync(),
|
requestHeaders: getRuntimeExtraHeadersSync(),
|
||||||
});
|
});
|
||||||
@@ -119,11 +174,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
|
|||||||
throw new DevTunnelUnavailableError(url);
|
throw new DevTunnelUnavailableError(url);
|
||||||
}
|
}
|
||||||
localPortByTarget.set(key, result.localPort);
|
localPortByTarget.set(key, result.localPort);
|
||||||
try {
|
rememberOriginalOrigin(url, result.localPort);
|
||||||
originByLocalPort.set(result.localPort, new URL(url).origin);
|
|
||||||
} catch {
|
|
||||||
// Unparseable input never reaches here; nothing to record.
|
|
||||||
}
|
|
||||||
return rewriteToLocalPort(url, result.localPort);
|
return rewriteToLocalPort(url, result.localPort);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DevTunnelUnavailableError) throw error;
|
if (error instanceof DevTunnelUnavailableError) throw error;
|
||||||
@@ -180,6 +231,10 @@ export const toDisplayUrl = (url: string): string => {
|
|||||||
const resetDevTunnelCache = (): void => {
|
const resetDevTunnelCache = (): void => {
|
||||||
localPortByTarget.clear();
|
localPortByTarget.clear();
|
||||||
originByLocalPort.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') {
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
};
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
import { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
|
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 withDesktopBridge = async <T>(handler: (cmd: string, args: Record<string, unknown>) => unknown | Promise<unknown>, run: () => Promise<T>): Promise<T> => {
|
||||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -408,14 +408,18 @@ export const desktopInstallIdGet = async (): Promise<string> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RELAY_PROBE_TIMEOUT_MS = 8_000;
|
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 (
|
const fetchRelayProbe = async (
|
||||||
tunnel: ReturnType<typeof createRelayTunnelClient>,
|
tunnel: ReturnType<typeof createRelayTunnelClient>,
|
||||||
path: string,
|
path: string,
|
||||||
|
timeoutMs: number,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = window.setTimeout(() => controller.abort(), RELAY_PROBE_TIMEOUT_MS);
|
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
return await tunnel.fetch(path, { ...init, signal: controller.signal });
|
return await tunnel.fetch(path, { ...init, signal: controller.signal });
|
||||||
} finally {
|
} 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
|
* Reachability and client-auth check for a relay host: open a throwaway E2EE
|
||||||
* tunnel, verify `/health`, then verify `/auth/session` with the saved bearer.
|
* tunnel, verify `/health`, then verify `/auth/session` with the saved bearer.
|
||||||
* Relay hosts have no HTTP address for `desktopHostProbe`. Hard timeout: a
|
* Relay hosts have no HTTP address for `desktopHostProbe`. Bounded by
|
||||||
* ghost relay registration (relay lost the host, host doesn't know) leaves the
|
* `RELAY_PROBE_DEADLINE_MS`: a ghost relay registration (relay lost the host,
|
||||||
* tunnel in `connecting` forever — the probe must report unreachable instead
|
* host doesn't know) leaves the tunnel reconnecting forever — the probe must
|
||||||
* of hanging every status/switch flow with it.
|
* 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 (
|
export const probeRelayDesktopHost = async (
|
||||||
relay: DesktopHostRelay,
|
relay: DesktopHostRelay,
|
||||||
@@ -444,9 +487,10 @@ export const probeRelayDesktopHost = async (
|
|||||||
hostEncPubJwk: relay.hostEncPubJwk,
|
hostEncPubJwk: relay.hostEncPubJwk,
|
||||||
});
|
});
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const deadline = startedAt + RELAY_PROBE_DEADLINE_MS;
|
||||||
let keep = false;
|
let keep = false;
|
||||||
try {
|
try {
|
||||||
const response = await fetchRelayProbe(tunnel, '/health');
|
const response = await fetchRelayProbeUntilDeadline(tunnel, '/health', deadline);
|
||||||
if (!response.ok) return { status: 'unreachable', latencyMs: 0 };
|
if (!response.ok) return { status: 'unreachable', latencyMs: 0 };
|
||||||
const headers = new Headers({ Accept: 'application/json' });
|
const headers = new Headers({ Accept: 'application/json' });
|
||||||
for (const [name, value] of Object.entries(options?.requestHeaders || {})) {
|
for (const [name, value] of Object.entries(options?.requestHeaders || {})) {
|
||||||
@@ -454,7 +498,7 @@ export const probeRelayDesktopHost = async (
|
|||||||
}
|
}
|
||||||
const clientToken = options?.clientToken?.trim();
|
const clientToken = options?.clientToken?.trim();
|
||||||
if (clientToken) headers.set('Authorization', `Bearer ${clientToken}`);
|
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) {
|
if (sessionResponse.status === 401 || sessionResponse.status === 403) {
|
||||||
return { status: 'auth', latencyMs: Math.max(0, Date.now() - startedAt) };
|
return { status: 'auth', latencyMs: Math.max(0, Date.now() - startedAt) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop';
|
import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop';
|
||||||
|
|
||||||
type InvokeArgs = Record<string, unknown>;
|
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>(
|
export const invokeDesktopCommand = async <TValue = unknown>(
|
||||||
command: string,
|
command: string,
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ const loadedFaces = new Set<string>();
|
|||||||
const pendingFaces = new Map<string, Promise<void>>();
|
const pendingFaces = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
const buildFontUrl = (source: FontFaceSource, weight: number) => {
|
const buildFontUrl = (source: FontFaceSource, weight: number) => {
|
||||||
|
if ('urls' in source) {
|
||||||
|
return source.urls[weight];
|
||||||
|
}
|
||||||
|
|
||||||
const packageName = encodeURIComponent(source.packageName).replace('%40', '@').replace('%2F', '/');
|
const packageName = encodeURIComponent(source.packageName).replace('%40', '@').replace('%2F', '/');
|
||||||
return `https://cdn.jsdelivr.net/npm/${packageName}/files/${source.filePrefix}-latin-${weight}-normal.woff2`;
|
return `https://cdn.jsdelivr.net/npm/${packageName}/files/${source.filePrefix}-latin-${weight}-normal.woff2`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 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;
|
family: string;
|
||||||
packageName: string;
|
|
||||||
filePrefix: string;
|
|
||||||
weights: number[];
|
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> {
|
export interface FontOptionDefinition<T extends string> {
|
||||||
id: T;
|
id: T;
|
||||||
label: string;
|
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',
|
stack: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||||
source: { family: 'Inter', packageName: '@fontsource/inter', filePrefix: 'inter', weights: [400, 500, 600] }
|
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',
|
id: 'geist-sans',
|
||||||
label: 'Geist Sans',
|
label: 'Geist Sans',
|
||||||
|
|||||||
@@ -214,6 +214,12 @@ export async function getGitBranches(directory: string): Promise<import('./api/t
|
|||||||
return gitHttp.getGitBranches(directory);
|
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 }> {
|
export async function deleteGitBranch(directory: string, payload: import('./api/types').GitDeleteBranchPayload): Promise<{ success: boolean }> {
|
||||||
const runtime = getRuntimeGit();
|
const runtime = getRuntimeGit();
|
||||||
if (runtime) return runtimeStatusMutation(directory, runtime.deleteGitBranch(directory, payload));
|
if (runtime) return runtimeStatusMutation(directory, runtime.deleteGitBranch(directory, payload));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
GitFileDiffResponse,
|
GitFileDiffResponse,
|
||||||
GetGitFileDiffOptions,
|
GetGitFileDiffOptions,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
GitUnpushedBranchCounts,
|
||||||
GitDeleteBranchPayload,
|
GitDeleteBranchPayload,
|
||||||
GitDeleteRemoteBranchPayload,
|
GitDeleteRemoteBranchPayload,
|
||||||
GitRemoveRemotePayload,
|
GitRemoveRemotePayload,
|
||||||
@@ -493,6 +494,16 @@ export async function getGitBranches(directory: string): Promise<GitBranch> {
|
|||||||
return response.json();
|
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 }> {
|
export async function deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> {
|
||||||
if (!payload?.branch) {
|
if (!payload?.branch) {
|
||||||
throw new Error('branch is required to delete a 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.openchamber.visual.option.followUpBehavior.queue.label': 'Warteschlange',
|
||||||
|
|
||||||
'settings.providers.page.quotaCredentials.accessToken': 'Zugriffstoken',
|
'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.refreshToken': 'Aktualisierungstoken',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token einfügen',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token einfügen',
|
||||||
'settings.view.nav.group.general': 'OpenChamber',
|
'settings.view.nav.group.general': 'OpenChamber',
|
||||||
|
|||||||
@@ -699,6 +699,20 @@ export const dict = {
|
|||||||
'gitView.commit.stageFilesHint': 'Stagen Sie Dateien, um Commit zu aktivieren.',
|
'gitView.commit.stageFilesHint': 'Stagen Sie Dateien, um Commit zu aktivieren.',
|
||||||
'gitView.commit.title': 'Commit',
|
'gitView.commit.title': 'Commit',
|
||||||
'gitView.common.cancel': 'Abbrechen',
|
'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.close': 'Schließen',
|
||||||
'gitView.common.done': 'Fertig',
|
'gitView.common.done': 'Fertig',
|
||||||
'gitView.common.processing': 'Verarbeitung läuft...',
|
'gitView.common.processing': 'Verarbeitung läuft...',
|
||||||
@@ -1429,6 +1443,8 @@ export const dict = {
|
|||||||
'chat.autoReview.reviewSessionLabel': 'Überprüfungssitzung',
|
'chat.autoReview.reviewSessionLabel': 'Überprüfungssitzung',
|
||||||
'chat.autoReview.actions.open': 'Öffnen',
|
'chat.autoReview.actions.open': 'Öffnen',
|
||||||
'chat.autoReview.actions.stop': 'Stoppen',
|
'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.label': 'Stücke',
|
||||||
'diffView.hunk.stage': 'Zu Staging hinzufügen',
|
'diffView.hunk.stage': 'Zu Staging hinzufügen',
|
||||||
'diffView.hunk.unstage': 'Aus Staging entfernen',
|
'diffView.hunk.unstage': 'Aus Staging entfernen',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.openCodeGo.delete': 'Delete',
|
'settings.providers.page.openCodeGo.delete': 'Delete',
|
||||||
'settings.providers.page.quotaCredentials.saved': '{provider} credentials saved.',
|
'settings.providers.page.quotaCredentials.saved': '{provider} credentials saved.',
|
||||||
'settings.providers.page.quotaCredentials.accessToken': 'Access token',
|
'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.refreshToken': 'Refresh token',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Paste token',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Paste token',
|
||||||
'settings.providers.page.openCodeGo.saveFailed': 'Could not validate OpenCode Go credentials.',
|
'settings.providers.page.openCodeGo.saveFailed': 'Could not validate OpenCode Go credentials.',
|
||||||
|
|||||||
@@ -795,6 +795,20 @@ export const dict = {
|
|||||||
'gitView.commit.stageFilesHint': 'Stage files to enable commit.',
|
'gitView.commit.stageFilesHint': 'Stage files to enable commit.',
|
||||||
'gitView.commit.title': 'Commit',
|
'gitView.commit.title': 'Commit',
|
||||||
'gitView.common.cancel': 'Cancel',
|
'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.close': 'Close',
|
||||||
'gitView.common.done': 'Done',
|
'gitView.common.done': 'Done',
|
||||||
'gitView.common.processing': 'Processing...',
|
'gitView.common.processing': 'Processing...',
|
||||||
@@ -1626,6 +1640,8 @@ export const dict = {
|
|||||||
'chat.autoReview.reviewSessionLabel': 'Review session',
|
'chat.autoReview.reviewSessionLabel': 'Review session',
|
||||||
'chat.autoReview.actions.open': 'Open',
|
'chat.autoReview.actions.open': 'Open',
|
||||||
'chat.autoReview.actions.stop': 'Stop',
|
'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.label': 'Hunks',
|
||||||
'diffView.hunk.stage': 'Stage',
|
'diffView.hunk.stage': 'Stage',
|
||||||
'diffView.hunk.unstage': 'Unstage',
|
'diffView.hunk.unstage': 'Unstage',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.openCodeGo.delete': 'Eliminar',
|
'settings.providers.page.openCodeGo.delete': 'Eliminar',
|
||||||
'settings.providers.page.quotaCredentials.saved': 'Credenciales de {provider} guardadas.',
|
'settings.providers.page.quotaCredentials.saved': 'Credenciales de {provider} guardadas.',
|
||||||
'settings.providers.page.quotaCredentials.accessToken': 'Token de acceso',
|
'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.refreshToken': 'Token de actualización',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Pega el token',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Pega el token',
|
||||||
'settings.providers.page.openCodeGo.saveFailed': 'No se pudieron validar las credenciales de OpenCode Go.',
|
'settings.providers.page.openCodeGo.saveFailed': 'No se pudieron validar las credenciales de OpenCode Go.',
|
||||||
|
|||||||
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.commit.stageFilesHint": "Prepara archivos para habilitar el commit.",
|
"gitView.commit.stageFilesHint": "Prepara archivos para habilitar el commit.",
|
||||||
"gitView.commit.title": "Commit",
|
"gitView.commit.title": "Commit",
|
||||||
"gitView.common.cancel": "Cancelar",
|
"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.close": "Cerrar",
|
||||||
"gitView.common.done": "Hecho",
|
"gitView.common.done": "Hecho",
|
||||||
"gitView.common.processing": "Procesando...",
|
"gitView.common.processing": "Procesando...",
|
||||||
@@ -1604,6 +1618,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.autoReview.reviewSessionLabel': 'Sesión de revisión',
|
'chat.autoReview.reviewSessionLabel': 'Sesión de revisión',
|
||||||
'chat.autoReview.actions.open': 'Abrir',
|
'chat.autoReview.actions.open': 'Abrir',
|
||||||
'chat.autoReview.actions.stop': 'Detener',
|
'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.label": "Fragmentos",
|
||||||
"diffView.hunk.stage": "Preparar",
|
"diffView.hunk.stage": "Preparar",
|
||||||
"diffView.hunk.unstage": "Quitar",
|
"diffView.hunk.unstage": "Quitar",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.openCodeGo.delete': 'Supprimer',
|
'settings.providers.page.openCodeGo.delete': 'Supprimer',
|
||||||
'settings.providers.page.quotaCredentials.saved': 'Identifiants de {provider} enregistrés.',
|
'settings.providers.page.quotaCredentials.saved': 'Identifiants de {provider} enregistrés.',
|
||||||
'settings.providers.page.quotaCredentials.accessToken': 'Jeton d’accès',
|
'settings.providers.page.quotaCredentials.accessToken': 'Jeton d’accès',
|
||||||
|
'settings.providers.page.quotaCredentials.usageToken': 'Jeton API d’utilisation',
|
||||||
|
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Exécutez cette commande dans votre terminal, puis collez le jeton ci-dessous. Il peut uniquement lire l’utilisation des crédits LLM et expire après 30 jours.',
|
||||||
'settings.providers.page.quotaCredentials.refreshToken': 'Jeton d’actualisation',
|
'settings.providers.page.quotaCredentials.refreshToken': 'Jeton d’actualisation',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Collez le jeton',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Collez le jeton',
|
||||||
'settings.providers.page.openCodeGo.saveFailed': 'Impossible de valider les identifiants OpenCode Go.',
|
'settings.providers.page.openCodeGo.saveFailed': 'Impossible de valider les identifiants OpenCode Go.',
|
||||||
|
|||||||
@@ -618,6 +618,20 @@ export const dict = {
|
|||||||
'gitView.commit.stageFilesHint': 'Ajoutez des fichiers à l’index pour activer le commit.',
|
'gitView.commit.stageFilesHint': 'Ajoutez des fichiers à l’index pour activer le commit.',
|
||||||
'gitView.commit.title': 'Commettre',
|
'gitView.commit.title': 'Commettre',
|
||||||
'gitView.common.cancel': 'Annuler',
|
'gitView.common.cancel': 'Annuler',
|
||||||
|
'gitView.branch.switchBlockedNotice': 'Modifications non commitées — le changement passe d’abord 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 d’abord.',
|
||||||
|
'gitView.dirtySwitch.descriptionPlural': 'Le passage à {branch} est suspendu pour ne pas perdre vos {count} fichiers modifiés. Commitez-les ou annulez-les d’abord.',
|
||||||
|
'gitView.dirtySwitch.commitAndSwitch': 'Commiter et changer',
|
||||||
|
'gitView.dirtySwitch.committedNotPushed': 'Commité sur {branch}. Le commit est local uniquement — il n’a pas été poussé.',
|
||||||
|
'gitView.dirtySwitch.pushAfterCommit': 'Pousser après le commit',
|
||||||
|
'gitView.dirtySwitch.pushFailed': 'Commité, mais le push a échoué — la branche n’a pas été changée.',
|
||||||
|
'gitView.dirtySwitch.actionFailed': 'L’action a échoué ; la branche n’a pas été changée.',
|
||||||
|
'gitView.dirtySwitch.revertAndSwitch': 'Annuler et changer',
|
||||||
|
'gitView.dirtySwitch.revertIncomplete': 'Certaines modifications n’ont pas pu être annulées, la branche n’a donc pas été changée.',
|
||||||
'gitView.common.close': 'Fermer',
|
'gitView.common.close': 'Fermer',
|
||||||
'gitView.common.done': 'Fait',
|
'gitView.common.done': 'Fait',
|
||||||
'gitView.common.processing': 'Traitement...',
|
'gitView.common.processing': 'Traitement...',
|
||||||
@@ -1390,6 +1404,8 @@ export const dict = {
|
|||||||
'chat.autoReview.reviewSessionLabel': 'Session de revue',
|
'chat.autoReview.reviewSessionLabel': 'Session de revue',
|
||||||
'chat.autoReview.actions.open': 'Ouvrir',
|
'chat.autoReview.actions.open': 'Ouvrir',
|
||||||
'chat.autoReview.actions.stop': 'Arrêter',
|
'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.label': 'Sections',
|
||||||
'diffView.hunk.stage': 'Préparer',
|
'diffView.hunk.stage': 'Préparer',
|
||||||
'diffView.hunk.unstage': 'Retirer',
|
'diffView.hunk.unstage': 'Retirer',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.openCodeGo.delete': '削除',
|
'settings.providers.page.openCodeGo.delete': '削除',
|
||||||
'settings.providers.page.quotaCredentials.saved': '{provider} の認証情報を保存しました。',
|
'settings.providers.page.quotaCredentials.saved': '{provider} の認証情報を保存しました。',
|
||||||
'settings.providers.page.quotaCredentials.accessToken': 'アクセストークン',
|
'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.refreshToken': '更新トークン',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'トークンを貼り付け',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'トークンを貼り付け',
|
||||||
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go の認証情報を検証できませんでした。',
|
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go の認証情報を検証できませんでした。',
|
||||||
|
|||||||
@@ -793,6 +793,20 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.commit.stageFilesHint': 'ファイルをステージするとコミットが有効になります。',
|
'gitView.commit.stageFilesHint': 'ファイルをステージするとコミットが有効になります。',
|
||||||
'gitView.commit.title': 'コミット',
|
'gitView.commit.title': 'コミット',
|
||||||
'gitView.common.cancel': 'キャンセル',
|
'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.close': '閉じる',
|
||||||
'gitView.common.done': '完了',
|
'gitView.common.done': '完了',
|
||||||
'gitView.common.processing': '処理中...',
|
'gitView.common.processing': '処理中...',
|
||||||
@@ -1631,6 +1645,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.autoReview.reviewSessionLabel': 'レビューセッション',
|
'chat.autoReview.reviewSessionLabel': 'レビューセッション',
|
||||||
'chat.autoReview.actions.open': '開く',
|
'chat.autoReview.actions.open': '開く',
|
||||||
'chat.autoReview.actions.stop': '停止',
|
'chat.autoReview.actions.stop': '停止',
|
||||||
|
'chat.draftDirtyNotice.tooltip': 'このブランチには未コミットのファイルがあります。\n新しいセッションからも見えます。コミットまたはワークツリーで分けられます。',
|
||||||
|
'chat.draftDirtyNotice.indicatorAria': 'このディレクトリに未コミットの変更があります',
|
||||||
'rightSidebar.contextNotesTodo.plan.defaultTitle': '計画',
|
'rightSidebar.contextNotesTodo.plan.defaultTitle': '計画',
|
||||||
'rightSidebar.contextNotesTodo.empty.selectProject': 'プロジェクトを選択してメモとTODOを追加します。',
|
'rightSidebar.contextNotesTodo.empty.selectProject': 'プロジェクトを選択してメモとTODOを追加します。',
|
||||||
'rightSidebar.contextNotesTodo.notes.placeholder': 'コンテキスト、リマインダー、リンクを記録',
|
'rightSidebar.contextNotesTodo.notes.placeholder': 'コンテキスト、リマインダー、リンクを記録',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.openCodeGo.delete': '삭제',
|
'settings.providers.page.openCodeGo.delete': '삭제',
|
||||||
'settings.providers.page.quotaCredentials.saved': '{provider} 인증 정보를 저장했습니다.',
|
'settings.providers.page.quotaCredentials.saved': '{provider} 인증 정보를 저장했습니다.',
|
||||||
'settings.providers.page.quotaCredentials.accessToken': '액세스 토큰',
|
'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.refreshToken': '새로 고침 토큰',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': '토큰 붙여넣기',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': '토큰 붙여넣기',
|
||||||
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go 인증 정보를 검증할 수 없습니다.',
|
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go 인증 정보를 검증할 수 없습니다.',
|
||||||
|
|||||||
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.commit.stageFilesHint': '커밋하려면 파일을 스테이징하세요.',
|
'gitView.commit.stageFilesHint': '커밋하려면 파일을 스테이징하세요.',
|
||||||
'gitView.commit.title': '커밋',
|
'gitView.commit.title': '커밋',
|
||||||
'gitView.common.cancel': '취소',
|
'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.close': '닫기',
|
||||||
'gitView.common.done': '완료',
|
'gitView.common.done': '완료',
|
||||||
'gitView.common.processing': '처리 중…',
|
'gitView.common.processing': '처리 중…',
|
||||||
@@ -1628,6 +1642,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.autoReview.reviewSessionLabel': '리뷰 세션',
|
'chat.autoReview.reviewSessionLabel': '리뷰 세션',
|
||||||
'chat.autoReview.actions.open': '열기',
|
'chat.autoReview.actions.open': '열기',
|
||||||
'chat.autoReview.actions.stop': '중지',
|
'chat.autoReview.actions.stop': '중지',
|
||||||
|
'chat.draftDirtyNotice.tooltip': '이 브랜치에는 커밋되지 않은 파일이 있습니다.\n새 세션에서도 보입니다. 커밋 또는 워크트리로 분리할 수 있습니다.',
|
||||||
|
'chat.draftDirtyNotice.indicatorAria': '이 디렉터리에 커밋되지 않은 변경 사항이 있습니다',
|
||||||
'diffView.hunk.label': '허크',
|
'diffView.hunk.label': '허크',
|
||||||
'diffView.hunk.stage': '스테이지',
|
'diffView.hunk.stage': '스테이지',
|
||||||
'diffView.hunk.unstage': '스테이지 해제',
|
'diffView.hunk.unstage': '스테이지 해제',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.openCodeGo.delete': 'Usuń',
|
'settings.providers.page.openCodeGo.delete': 'Usuń',
|
||||||
'settings.providers.page.quotaCredentials.saved': 'Dane uwierzytelniające {provider} zostały zapisane.',
|
'settings.providers.page.quotaCredentials.saved': 'Dane uwierzytelniające {provider} zostały zapisane.',
|
||||||
'settings.providers.page.quotaCredentials.accessToken': 'Token dostępu',
|
'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.refreshToken': 'Token odświeżania',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Wklej token',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Wklej token',
|
||||||
'settings.providers.page.openCodeGo.saveFailed': 'Nie udało się sprawdzić danych OpenCode Go.',
|
'settings.providers.page.openCodeGo.saveFailed': 'Nie udało się sprawdzić danych OpenCode Go.',
|
||||||
|
|||||||
@@ -1844,6 +1844,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.autoReview.reviewSessionLabel': 'Sesja review',
|
'chat.autoReview.reviewSessionLabel': 'Sesja review',
|
||||||
'chat.autoReview.actions.open': 'Otwórz',
|
'chat.autoReview.actions.open': 'Otwórz',
|
||||||
'chat.autoReview.actions.stop': 'Zatrzymaj',
|
'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.label': 'Fragmenty',
|
||||||
'diffView.hunk.stage': 'Przygotuj',
|
'diffView.hunk.stage': 'Przygotuj',
|
||||||
'diffView.hunk.unstage': 'Cofnij',
|
'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.stageFilesHint': 'Dodaj pliki do indeksu, aby włączyć commit.',
|
||||||
'gitView.commit.title': 'Commit',
|
'gitView.commit.title': 'Commit',
|
||||||
'gitView.common.cancel': 'Anuluj',
|
'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.close': 'Zamknij',
|
||||||
'gitView.common.done': 'Gotowe',
|
'gitView.common.done': 'Gotowe',
|
||||||
'gitView.common.processing': 'Przetwarzanie...',
|
'gitView.common.processing': 'Przetwarzanie...',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.openCodeGo.delete': 'Excluir',
|
'settings.providers.page.openCodeGo.delete': 'Excluir',
|
||||||
'settings.providers.page.quotaCredentials.saved': 'Credenciais de {provider} salvas.',
|
'settings.providers.page.quotaCredentials.saved': 'Credenciais de {provider} salvas.',
|
||||||
'settings.providers.page.quotaCredentials.accessToken': 'Token de acesso',
|
'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.refreshToken': 'Token de atualização',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Cole o token',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Cole o token',
|
||||||
'settings.providers.page.openCodeGo.saveFailed': 'Não foi possível validar as credenciais do OpenCode Go.',
|
'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.stageFilesHint": "Adicione arquivos ao stage para habilitar o commit.",
|
||||||
"gitView.commit.title": "Commit",
|
"gitView.commit.title": "Commit",
|
||||||
"gitView.common.cancel": "Cancelar",
|
"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.close": "Fechar",
|
||||||
"gitView.common.done": "Concluído",
|
"gitView.common.done": "Concluído",
|
||||||
"gitView.common.processing": "Procesando...",
|
"gitView.common.processing": "Procesando...",
|
||||||
@@ -1604,6 +1618,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.autoReview.reviewSessionLabel': 'Sessão de revisão',
|
'chat.autoReview.reviewSessionLabel': 'Sessão de revisão',
|
||||||
'chat.autoReview.actions.open': 'Abrir',
|
'chat.autoReview.actions.open': 'Abrir',
|
||||||
'chat.autoReview.actions.stop': 'Parar',
|
'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.label": "Trechos",
|
||||||
"diffView.hunk.stage": "Preparar",
|
"diffView.hunk.stage": "Preparar",
|
||||||
"diffView.hunk.unstage": "Remover",
|
"diffView.hunk.unstage": "Remover",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.openCodeGo.delete': 'Sil',
|
'settings.providers.page.openCodeGo.delete': 'Sil',
|
||||||
'settings.providers.page.quotaCredentials.saved': '{provider} kimlik bilgileri kaydedildi.',
|
'settings.providers.page.quotaCredentials.saved': '{provider} kimlik bilgileri kaydedildi.',
|
||||||
'settings.providers.page.quotaCredentials.accessToken': 'Erişim token\'ı',
|
'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.refreshToken': 'Yenileme token\'ı',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token\'ı yapıştır',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token\'ı yapıştır',
|
||||||
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go kimlik bilgileri doğrulanamadı.',
|
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go kimlik bilgileri doğrulanamadı.',
|
||||||
|
|||||||
@@ -777,6 +777,20 @@ export const dict = {
|
|||||||
'gitView.commit.stageFilesHint': 'Commit\'i etkinleştirmek için dosyaları stage edin.',
|
'gitView.commit.stageFilesHint': 'Commit\'i etkinleştirmek için dosyaları stage edin.',
|
||||||
'gitView.commit.title': 'Commit',
|
'gitView.commit.title': 'Commit',
|
||||||
'gitView.common.cancel': 'İptal',
|
'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.close': 'Kapat',
|
||||||
'gitView.common.done': 'Tamam',
|
'gitView.common.done': 'Tamam',
|
||||||
'gitView.common.processing': 'İşleniyor...',
|
'gitView.common.processing': 'İşleniyor...',
|
||||||
@@ -796,10 +810,8 @@ export const dict = {
|
|||||||
'gitView.conflict.resolveNewSession': 'Yeni session\'da çöz',
|
'gitView.conflict.resolveNewSession': 'Yeni session\'da çöz',
|
||||||
'gitView.empty.cleanDescription': 'Tüm değişiklikler commit edildi',
|
'gitView.empty.cleanDescription': 'Tüm değişiklikler commit edildi',
|
||||||
'gitView.empty.cleanTitle': 'Working tree temiz',
|
'gitView.empty.cleanTitle': 'Working tree temiz',
|
||||||
'gitView.empty.discoveringRepositories': 'Git repository\'leri aranıyor...',
|
'gitView.empty.discoveringRepositories': 'Git depoları aranıyor...',
|
||||||
'gitView.empty.discoverFailed': 'Git repository\'leri taranamadı',
|
'gitView.empty.discoverFailed': 'Git depoları taranamadı',
|
||||||
'gitView.empty.retryDiscovery': 'Tekrar dene',
|
|
||||||
'gitView.empty.selectRepositoryPlaceholder': 'Bir repository seçin...',
|
|
||||||
'gitView.empty.pullBehindPlural': '{count} commit pull et',
|
'gitView.empty.pullBehindPlural': '{count} commit pull et',
|
||||||
'gitView.empty.pullBehindSingle': '{count} commit pull et',
|
'gitView.empty.pullBehindSingle': '{count} commit pull et',
|
||||||
'gitView.header.identityTooltip': 'Git kimliği',
|
'gitView.header.identityTooltip': 'Git kimliği',
|
||||||
@@ -963,6 +975,8 @@ export const dict = {
|
|||||||
'gitView.conflict.noDetailsAvailable': 'Çakışma detayları mevcut değil',
|
'gitView.conflict.noDetailsAvailable': 'Çakışma detayları mevcut değil',
|
||||||
'gitView.empty.notGitRepository': 'Bu dizin bir Git repository\'si 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.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.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.worktreeFeaturesUnavailable': 'Bu çalışma alanı modunda worktree özellikleri kullanılamıyor.',
|
||||||
'gitView.empty.worktreeSetupDescription': 'Worktree kurulumu tamamlanıyor ve repository durumu hazırlanı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.reviewSessionLabel': 'İnceleme session\'ı',
|
||||||
'chat.autoReview.actions.open': 'Aç',
|
'chat.autoReview.actions.open': 'Aç',
|
||||||
'chat.autoReview.actions.stop': 'Durdur',
|
'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.label': 'Hunk\'lar',
|
||||||
'diffView.hunk.stage': 'Stage',
|
'diffView.hunk.stage': 'Stage',
|
||||||
'diffView.hunk.unstage': 'Unstage',
|
'diffView.hunk.unstage': 'Unstage',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.openCodeGo.delete': 'Видалити',
|
'settings.providers.page.openCodeGo.delete': 'Видалити',
|
||||||
'settings.providers.page.quotaCredentials.saved': 'Облікові дані {provider} збережено.',
|
'settings.providers.page.quotaCredentials.saved': 'Облікові дані {provider} збережено.',
|
||||||
'settings.providers.page.quotaCredentials.accessToken': 'Токен доступу',
|
'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.refreshToken': 'Токен оновлення',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Вставте токен',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Вставте токен',
|
||||||
'settings.providers.page.openCodeGo.saveFailed': 'Не вдалося перевірити дані OpenCode Go.',
|
'settings.providers.page.openCodeGo.saveFailed': 'Не вдалося перевірити дані OpenCode Go.',
|
||||||
|
|||||||
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.commit.stageFilesHint": "Додайте файли до індексу, щоб увімкнути коміт.",
|
"gitView.commit.stageFilesHint": "Додайте файли до індексу, щоб увімкнути коміт.",
|
||||||
"gitView.commit.title": "Коміт",
|
"gitView.commit.title": "Коміт",
|
||||||
"gitView.common.cancel": "Скасувати",
|
"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.close": "Закрити",
|
||||||
"gitView.common.done": "Готово",
|
"gitView.common.done": "Готово",
|
||||||
"gitView.common.processing": "Обробка...",
|
"gitView.common.processing": "Обробка...",
|
||||||
@@ -1604,6 +1618,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.autoReview.reviewSessionLabel': 'Сесія ревʼю',
|
'chat.autoReview.reviewSessionLabel': 'Сесія ревʼю',
|
||||||
'chat.autoReview.actions.open': 'Відкрити',
|
'chat.autoReview.actions.open': 'Відкрити',
|
||||||
'chat.autoReview.actions.stop': 'Зупинити',
|
'chat.autoReview.actions.stop': 'Зупинити',
|
||||||
|
'chat.draftDirtyNotice.tooltip': 'У цій гілці є незакомічені файли.\nНова сесія бачитиме їх. Коміт або worktree тримають їх окремо.',
|
||||||
|
'chat.draftDirtyNotice.indicatorAria': 'Незакомічені зміни в цьому каталозі',
|
||||||
"diffView.hunk.label": "Шматки",
|
"diffView.hunk.label": "Шматки",
|
||||||
"diffView.hunk.stage": "Додати",
|
"diffView.hunk.stage": "Додати",
|
||||||
"diffView.hunk.unstage": "Прибрати",
|
"diffView.hunk.unstage": "Прибрати",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.openCodeGo.delete': '删除',
|
'settings.providers.page.openCodeGo.delete': '删除',
|
||||||
'settings.providers.page.quotaCredentials.saved': '已保存 {provider} 凭据。',
|
'settings.providers.page.quotaCredentials.saved': '已保存 {provider} 凭据。',
|
||||||
'settings.providers.page.quotaCredentials.accessToken': '访问令牌',
|
'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.refreshToken': '刷新令牌',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': '粘贴令牌',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': '粘贴令牌',
|
||||||
'settings.providers.page.openCodeGo.saveFailed': '无法验证 OpenCode Go 凭据。',
|
'settings.providers.page.openCodeGo.saveFailed': '无法验证 OpenCode Go 凭据。',
|
||||||
|
|||||||
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.commit.stageFilesHint': '暂存文件以启用提交。',
|
'gitView.commit.stageFilesHint': '暂存文件以启用提交。',
|
||||||
'gitView.commit.title': '提交',
|
'gitView.commit.title': '提交',
|
||||||
'gitView.common.cancel': '取消',
|
'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.close': '关闭',
|
||||||
'gitView.common.done': '完成',
|
'gitView.common.done': '完成',
|
||||||
'gitView.common.processing': '处理中...',
|
'gitView.common.processing': '处理中...',
|
||||||
@@ -1592,6 +1606,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.autoReview.reviewSessionLabel': '审查会话',
|
'chat.autoReview.reviewSessionLabel': '审查会话',
|
||||||
'chat.autoReview.actions.open': '打开',
|
'chat.autoReview.actions.open': '打开',
|
||||||
'chat.autoReview.actions.stop': '停止',
|
'chat.autoReview.actions.stop': '停止',
|
||||||
|
'chat.draftDirtyNotice.tooltip': '此分支有未提交的文件。\n新会话会看到它们。提交或工作树可将它们分开。',
|
||||||
|
'chat.draftDirtyNotice.indicatorAria': '此目录有未提交的更改',
|
||||||
'diffView.hunk.label': '代码块',
|
'diffView.hunk.label': '代码块',
|
||||||
'diffView.hunk.stage': '暂存',
|
'diffView.hunk.stage': '暂存',
|
||||||
'diffView.hunk.unstage': '取消暂存',
|
'diffView.hunk.unstage': '取消暂存',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.openCodeGo.delete': '刪除',
|
'settings.providers.page.openCodeGo.delete': '刪除',
|
||||||
'settings.providers.page.quotaCredentials.saved': '已儲存 {provider} 憑證。',
|
'settings.providers.page.quotaCredentials.saved': '已儲存 {provider} 憑證。',
|
||||||
'settings.providers.page.quotaCredentials.accessToken': '存取權杖',
|
'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.refreshToken': '重新整理權杖',
|
||||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': '貼上權杖',
|
'settings.providers.page.quotaCredentials.tokenPlaceholder': '貼上權杖',
|
||||||
'settings.providers.page.openCodeGo.saveFailed': '無法驗證 OpenCode Go 憑證。',
|
'settings.providers.page.openCodeGo.saveFailed': '無法驗證 OpenCode Go 憑證。',
|
||||||
|
|||||||
@@ -809,6 +809,20 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.commit.stageFilesHint': '暫存文件以啟用提交。',
|
'gitView.commit.stageFilesHint': '暫存文件以啟用提交。',
|
||||||
'gitView.commit.title': '提交',
|
'gitView.commit.title': '提交',
|
||||||
'gitView.common.cancel': '取消',
|
'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.close': '關閉',
|
||||||
'gitView.common.done': '完成',
|
'gitView.common.done': '完成',
|
||||||
'gitView.common.processing': '處理中...',
|
'gitView.common.processing': '處理中...',
|
||||||
@@ -1602,6 +1616,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.autoReview.reviewSessionLabel': '審查工作階段',
|
'chat.autoReview.reviewSessionLabel': '審查工作階段',
|
||||||
'chat.autoReview.actions.open': '開啟',
|
'chat.autoReview.actions.open': '開啟',
|
||||||
'chat.autoReview.actions.stop': '停止',
|
'chat.autoReview.actions.stop': '停止',
|
||||||
|
'chat.draftDirtyNotice.tooltip': '此分支有未提交的檔案。\n新的工作階段會看到它們。提交或工作樹可將它們分開。',
|
||||||
|
'chat.draftDirtyNotice.indicatorAria': '此目錄有未提交的變更',
|
||||||
'diffView.hunk.label': '程式碼區塊',
|
'diffView.hunk.label': '程式碼區塊',
|
||||||
'diffView.hunk.stage': '暫存',
|
'diffView.hunk.stage': '暫存',
|
||||||
'diffView.hunk.unstage': '取消暫存',
|
'diffView.hunk.unstage': '取消暫存',
|
||||||
|
|||||||
@@ -875,6 +875,7 @@ describe('updateDesktopSettings', () => {
|
|||||||
expect(synced.length).toBeGreaterThan(0);
|
expect(synced.length).toBeGreaterThan(0);
|
||||||
const bootstrapSync = synced.find((detail) => detail.bootstrap);
|
const bootstrapSync = synced.find((detail) => detail.bootstrap);
|
||||||
expect(bootstrapSync).toBeTruthy();
|
expect(bootstrapSync).toBeTruthy();
|
||||||
|
expect(bootstrapSync?.adoptTheme).toBe(true);
|
||||||
expect(bootstrapSync?.settings.useSystemTheme).toBe(undefined);
|
expect(bootstrapSync?.settings.useSystemTheme).toBe(undefined);
|
||||||
expect(bootstrapSync?.settings.lightThemeId).toBe(undefined);
|
expect(bootstrapSync?.settings.lightThemeId).toBe(undefined);
|
||||||
expect(bootstrapSync?.settings.darkThemeId).toBe(undefined);
|
expect(bootstrapSync?.settings.darkThemeId).toBe(undefined);
|
||||||
@@ -905,8 +906,38 @@ describe('updateDesktopSettings', () => {
|
|||||||
|
|
||||||
expect(synced.length).toBeGreaterThan(0);
|
expect(synced.length).toBeGreaterThan(0);
|
||||||
expect(synced.every((detail) => detail.bootstrap === false)).toBe(true);
|
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);
|
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)', () => {
|
describe('unload lifecycle flush (#2197)', () => {
|
||||||
|
|||||||
@@ -205,14 +205,18 @@ export interface SettingsSyncedDetail {
|
|||||||
not filtered; listeners gate their adoption on this flag and keep their
|
not filtered; listeners gate their adoption on this flag and keep their
|
||||||
live state for the fields they own. */
|
live state for the fields they own. */
|
||||||
bootstrap: boolean;
|
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') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.dispatchEvent(new CustomEvent<SettingsSyncedDetail>('openchamber:settings-synced', {
|
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;
|
_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 bootstrap = options?.bootstrap !== false;
|
||||||
|
const adoptTheme = options?.adoptTheme ?? bootstrap;
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2030,7 +2035,7 @@ export const syncDesktopSettings = async (options?: { bootstrap?: boolean }): Pr
|
|||||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchSettingsSynced(authoritativeSettings, bootstrap);
|
dispatchSettingsSynced(authoritativeSettings, bootstrap, adoptTheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
|
|||||||
{ id: 'opencode-go', name: 'OpenCode Go' },
|
{ id: 'opencode-go', name: 'OpenCode Go' },
|
||||||
{ id: 'crof', name: 'CrofAI' },
|
{ id: 'crof', name: 'CrofAI' },
|
||||||
{ id: 'deepseek', name: 'DeepSeek' },
|
{ id: 'deepseek', name: 'DeepSeek' },
|
||||||
|
{ id: 'exe-dev', name: 'exe.dev' },
|
||||||
{ id: 'neuralwatt', name: 'NeuralWatt' },
|
{ id: 'neuralwatt', name: 'NeuralWatt' },
|
||||||
{ id: 'xai', name: 'xAI' },
|
{ id: 'xai', name: 'xAI' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt: trimmed,
|
prompt: trimmed,
|
||||||
system: NOTES_SYSTEM_PROMPT,
|
system: NOTES_SYSTEM_PROMPT,
|
||||||
|
sessionID: sessionId || undefined,
|
||||||
restrictToPreferredProvider: true,
|
restrictToPreferredProvider: true,
|
||||||
...(preferredProviderID ? { preferredProviderID } : {}),
|
...(preferredProviderID ? { preferredProviderID } : {}),
|
||||||
...(preferredModelID ? { preferredModelID } : {}),
|
...(preferredModelID ? { preferredModelID } : {}),
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Examples:
|
|||||||
- `useFeatureFlagsStore.ts`
|
- `useFeatureFlagsStore.ts`
|
||||||
- `useUpdateStore.ts`
|
- `useUpdateStore.ts`
|
||||||
|
|
||||||
These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted.
|
These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. The team filter is the one that is not a plain preference: a Linear team belongs to one workspace, and each OpenChamber instance has its own Linear login, so it is persisted per instance in `linearIssueListTeamIdByRuntime` and the flat `linearIssueListTeamId` is derived from it by `applyLinearIssueListFiltersForRuntime` — on an instance switch and when the rail mounts, since rehydration can run before the runtime endpoint is known. Carried across, a team id filters the new instance's list down to nothing. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted.
|
||||||
|
|
||||||
Context-panel session chats mount only the active chat iframe. After installing
|
Context-panel session chats mount only the active chat iframe. After installing
|
||||||
its message listener, the iframe requests its authoritative visibility from the
|
its message listener, the iframe requests its authoritative visibility from the
|
||||||
@@ -84,7 +84,7 @@ Permission auto-accept policy is authoritative in the active Web server or VS Co
|
|||||||
|
|
||||||
Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover.
|
Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover.
|
||||||
|
|
||||||
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive.
|
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty. Theme fields are the exception: only bootstrap-grade theme adoption applies fields supplied by the server, while omitted fields preserve this window's current runtime-scoped theme and settings save echoes never adopt a theme. VS Code settings broadcasts may still adopt shared workspace pointers without replacing each webview's editor-derived theme. Transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive.
|
||||||
|
|
||||||
Project ordering defaults to manual. Session display persistence v3 migrates the previously shipped `recent` project order to `manual` while preserving every other explicit sort mode.
|
Project ordering defaults to manual. Session display persistence v3 migrates the previously shipped `recent` project order to `manual` while preserving every other explicit sort mode.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||||
|
import type { McpStatus } from '@opencode-ai/sdk/v2';
|
||||||
|
import type { McpStatusMap } from './useMcpStore';
|
||||||
|
|
||||||
|
type Deferred<T> = { promise: Promise<T>; resolve: (value: T) => void };
|
||||||
|
const deferred = <T>(): Deferred<T> => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((res) => { resolve = res; });
|
||||||
|
return { promise, resolve };
|
||||||
|
};
|
||||||
|
|
||||||
|
type McpStatusResult = Awaited<ReturnType<ReturnType<typeof opencodeModule.opencodeClient.getApiClient>['mcp']['status']>>;
|
||||||
|
let mcpStatusResponse: Deferred<McpStatusResult> = deferred();
|
||||||
|
const opencodeModule = await import('@/lib/opencode/client');
|
||||||
|
// Derived from the real client rather than spread from it: the client is a
|
||||||
|
// class instance, so a spread drops every prototype method the other modules
|
||||||
|
// loaded in this process call at import time.
|
||||||
|
// SAFETY: `Object.create` returns `any`; the object delegates to the real
|
||||||
|
// client for everything the two overrides below do not define.
|
||||||
|
const opencodeClientStub = Object.create(opencodeModule.opencodeClient) as typeof opencodeModule.opencodeClient;
|
||||||
|
// The SDK client is derived the same way, so only `mcp.status` is replaced and
|
||||||
|
// every other endpoint keeps its real implementation and type.
|
||||||
|
type McpApiClient = ReturnType<typeof opencodeModule.opencodeClient.getApiClient>;
|
||||||
|
const realApiClient = opencodeModule.opencodeClient.getApiClient();
|
||||||
|
const mcpApiStub: McpApiClient = Object.create(realApiClient, {
|
||||||
|
mcp: { value: { ...realApiClient.mcp, status: () => mcpStatusResponse.promise } },
|
||||||
|
});
|
||||||
|
opencodeClientStub.getApiClient = () => mcpApiStub;
|
||||||
|
opencodeClientStub.getScopedApiClient = () => mcpApiStub;
|
||||||
|
mock.module('@/lib/opencode/client', () => ({ ...opencodeModule, opencodeClient: opencodeClientStub }));
|
||||||
|
|
||||||
|
let skillsResponse: Deferred<Response> = deferred();
|
||||||
|
const runtimeFetchModule = await import('@/lib/runtime-fetch');
|
||||||
|
mock.module('@/lib/runtime-fetch', () => ({
|
||||||
|
...runtimeFetchModule,
|
||||||
|
runtimeFetch: () => skillsResponse.promise,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { useMcpStore } = await import('./useMcpStore');
|
||||||
|
const { useSkillsStore } = await import('./useSkillsStore');
|
||||||
|
|
||||||
|
const mcpStatusResult = (data: McpStatusMap): McpStatusResult => ({
|
||||||
|
data,
|
||||||
|
request: new Request('http://localhost/mcp'),
|
||||||
|
response: new Response(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectedServer = (name: string): McpStatusMap => ({
|
||||||
|
// SAFETY: the store only reads `status` off each entry; the SDK type carries
|
||||||
|
// fields no consumer in this test path touches.
|
||||||
|
[name]: { status: 'connected' } as McpStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('instance-scoped stores reject responses from the previous instance', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mcpStatusResponse = deferred();
|
||||||
|
skillsResponse = deferred();
|
||||||
|
useMcpStore.getState().resetForRuntimeSwitch();
|
||||||
|
useSkillsStore.getState().resetForRuntimeSwitch();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an MCP status in flight during a switch does not land in the new instance', async () => {
|
||||||
|
const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true });
|
||||||
|
|
||||||
|
useMcpStore.getState().resetForRuntimeSwitch();
|
||||||
|
mcpStatusResponse.resolve(mcpStatusResult(connectedServer('from-instance-a')));
|
||||||
|
await refresh;
|
||||||
|
|
||||||
|
expect(useMcpStore.getState().getStatusForDirectory('/repo')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an MCP status that arrives with no switch is stored', async () => {
|
||||||
|
const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true });
|
||||||
|
mcpStatusResponse.resolve(mcpStatusResult(connectedServer('server-a')));
|
||||||
|
await refresh;
|
||||||
|
|
||||||
|
expect(Object.keys(useMcpStore.getState().getStatusForDirectory('/repo'))).toEqual(['server-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a skills load in flight during a switch does not land in the new instance', async () => {
|
||||||
|
const load = useSkillsStore.getState().loadSkills('/repo');
|
||||||
|
|
||||||
|
useSkillsStore.getState().resetForRuntimeSwitch();
|
||||||
|
skillsResponse.resolve(new Response(
|
||||||
|
JSON.stringify({ skills: [{ name: 'from-instance-a', path: '/repo/.agents/skills/a/SKILL.md' }] }),
|
||||||
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||||
|
));
|
||||||
|
await load;
|
||||||
|
|
||||||
|
expect(useSkillsStore.getState().skillsByDirectory['/repo']).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,8 @@ type GitHubAuthStore = {
|
|||||||
runtimeGitHub?: RuntimeAPIs['github'],
|
runtimeGitHub?: RuntimeAPIs['github'],
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean }
|
||||||
) => Promise<GitHubAuthStatusWithError | null>;
|
) => Promise<GitHubAuthStatusWithError | null>;
|
||||||
|
/** Same instance-scoping as Linear: the login lives on the connected instance. */
|
||||||
|
resetForRuntimeSwitch: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchStatus = async (
|
const fetchStatus = async (
|
||||||
@@ -36,6 +38,9 @@ const fetchStatus = async (
|
|||||||
|
|
||||||
// In-flight dedup for refreshStatus
|
// In-flight dedup for refreshStatus
|
||||||
let _inFlightAuthRefresh: Promise<GitHubAuthStatusWithError | null> | null = null;
|
let _inFlightAuthRefresh: Promise<GitHubAuthStatusWithError | null> | null = null;
|
||||||
|
// Bumped by every reset so a response already in flight for the previous
|
||||||
|
// instance cannot write itself into the new instance's status.
|
||||||
|
let authGeneration = 0;
|
||||||
|
|
||||||
export const useGitHubAuthStore = create<GitHubAuthStore>((set, get) => ({
|
export const useGitHubAuthStore = create<GitHubAuthStore>((set, get) => ({
|
||||||
status: null,
|
status: null,
|
||||||
@@ -50,13 +55,16 @@ export const useGitHubAuthStore = create<GitHubAuthStore>((set, get) => ({
|
|||||||
|
|
||||||
if (_inFlightAuthRefresh) return _inFlightAuthRefresh;
|
if (_inFlightAuthRefresh) return _inFlightAuthRefresh;
|
||||||
|
|
||||||
|
const generation = authGeneration;
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
_inFlightAuthRefresh = (async () => {
|
_inFlightAuthRefresh = (async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await fetchStatus(runtimeGitHub);
|
const payload = await fetchStatus(runtimeGitHub);
|
||||||
|
if (generation !== authGeneration) return null;
|
||||||
set({ status: payload, isLoading: false, hasChecked: true });
|
set({ status: payload, isLoading: false, hasChecked: true });
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== authGeneration) return null;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
set({
|
set({
|
||||||
status: { connected: false, error: message },
|
status: { connected: false, error: message },
|
||||||
@@ -69,4 +77,9 @@ export const useGitHubAuthStore = create<GitHubAuthStore>((set, get) => ({
|
|||||||
|
|
||||||
return _inFlightAuthRefresh;
|
return _inFlightAuthRefresh;
|
||||||
},
|
},
|
||||||
|
resetForRuntimeSwitch: () => {
|
||||||
|
authGeneration += 1;
|
||||||
|
_inFlightAuthRefresh = null;
|
||||||
|
set({ status: null, isLoading: false, hasChecked: false });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
|
import type { LinearAPI, LinearAuthStatus } from "@/lib/api/types"
|
||||||
|
|
||||||
|
mock.module("@/lib/runtime-fetch", () => ({ runtimeFetch: async () => new Response("{}") }))
|
||||||
|
|
||||||
|
const { useLinearAuthStore } = await import("./useLinearAuthStore")
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>((res) => { resolve = res })
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only `authStatus` is exercised here; the rest of the surface is present so
|
||||||
|
// the stub is a real `LinearAPI` rather than an assertion over a fragment.
|
||||||
|
const unreachable = () => Promise.reject(new Error("not used in this test"))
|
||||||
|
const linearApi = (authStatus: LinearAPI["authStatus"]): LinearAPI => ({
|
||||||
|
authStatus,
|
||||||
|
authStart: unreachable,
|
||||||
|
authDisconnect: unreachable,
|
||||||
|
authActivate: unreachable,
|
||||||
|
issuesList: unreachable,
|
||||||
|
issueGet: unreachable,
|
||||||
|
issueStates: unreachable,
|
||||||
|
issueUpdate: unreachable,
|
||||||
|
mappingGet: unreachable,
|
||||||
|
mappingSet: unreachable,
|
||||||
|
sessionStatusPost: unreachable,
|
||||||
|
preferencesGet: unreachable,
|
||||||
|
preferencesSet: unreachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Linear auth is scoped to the connected instance", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useLinearAuthStore.getState().resetForRuntimeSwitch()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a switch drops the previous instance's login", async () => {
|
||||||
|
await useLinearAuthStore.getState().refreshStatus(
|
||||||
|
linearApi(async () => ({ connected: true })),
|
||||||
|
{ force: true },
|
||||||
|
)
|
||||||
|
expect(useLinearAuthStore.getState().status?.connected).toBe(true)
|
||||||
|
|
||||||
|
useLinearAuthStore.getState().resetForRuntimeSwitch()
|
||||||
|
|
||||||
|
expect(useLinearAuthStore.getState().status).toBeNull()
|
||||||
|
expect(useLinearAuthStore.getState().hasChecked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a status still in flight for the previous instance cannot land in the new one", async () => {
|
||||||
|
const pending = deferred<LinearAuthStatus>()
|
||||||
|
const refresh = useLinearAuthStore.getState().refreshStatus(
|
||||||
|
linearApi(() => pending.promise),
|
||||||
|
{ force: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
useLinearAuthStore.getState().resetForRuntimeSwitch()
|
||||||
|
pending.resolve({ connected: true })
|
||||||
|
await refresh
|
||||||
|
|
||||||
|
expect(useLinearAuthStore.getState().status).toBeNull()
|
||||||
|
expect(useLinearAuthStore.getState().hasChecked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a failed check is not an authoritative disconnect", async () => {
|
||||||
|
await useLinearAuthStore.getState().refreshStatus(
|
||||||
|
linearApi(async () => ({ connected: true })),
|
||||||
|
{ force: true },
|
||||||
|
)
|
||||||
|
await useLinearAuthStore.getState().refreshStatus(
|
||||||
|
linearApi(async () => { throw new Error("offline") }),
|
||||||
|
{ force: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(useLinearAuthStore.getState().status?.connected).toBe(true)
|
||||||
|
expect(useLinearAuthStore.getState().status?.error).toBe("offline")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,13 @@ type LinearAuthStore = {
|
|||||||
runtimeLinear?: RuntimeAPIs['linear'],
|
runtimeLinear?: RuntimeAPIs['linear'],
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean }
|
||||||
) => Promise<LinearAuthStatusWithError | null>;
|
) => Promise<LinearAuthStatusWithError | null>;
|
||||||
|
/**
|
||||||
|
* Linear is authenticated on the OpenChamber instance, not in the browser, so
|
||||||
|
* this status belongs to whichever instance is connected. Switching instances
|
||||||
|
* must drop it — otherwise the previous instance's login stays on screen and
|
||||||
|
* its issue surfaces remain usable against a runtime that has no Linear at all.
|
||||||
|
*/
|
||||||
|
resetForRuntimeSwitch: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchStatus = async (
|
const fetchStatus = async (
|
||||||
@@ -24,6 +31,9 @@ const fetchStatus = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
let inFlightAuthRefresh: Promise<LinearAuthStatusWithError | null> | null = null;
|
let inFlightAuthRefresh: Promise<LinearAuthStatusWithError | null> | null = null;
|
||||||
|
// Bumped by every reset so a response already in flight for the previous
|
||||||
|
// instance cannot write itself into the new instance's status.
|
||||||
|
let authGeneration = 0;
|
||||||
|
|
||||||
export const useLinearAuthStore = create<LinearAuthStore>((set, get) => ({
|
export const useLinearAuthStore = create<LinearAuthStore>((set, get) => ({
|
||||||
status: null,
|
status: null,
|
||||||
@@ -41,13 +51,16 @@ export const useLinearAuthStore = create<LinearAuthStore>((set, get) => ({
|
|||||||
|
|
||||||
if (inFlightAuthRefresh) return inFlightAuthRefresh;
|
if (inFlightAuthRefresh) return inFlightAuthRefresh;
|
||||||
|
|
||||||
|
const generation = authGeneration;
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
inFlightAuthRefresh = (async () => {
|
inFlightAuthRefresh = (async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await fetchStatus(runtimeLinear);
|
const payload = await fetchStatus(runtimeLinear);
|
||||||
|
if (generation !== authGeneration) return null;
|
||||||
set({ status: payload, isLoading: false, hasChecked: true });
|
set({ status: payload, isLoading: false, hasChecked: true });
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== authGeneration) return null;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
// A failed request is not an authoritative disconnect. Keep the last
|
// A failed request is not an authoritative disconnect. Keep the last
|
||||||
// known status and leave `hasChecked` false so the next caller retries
|
// known status and leave `hasChecked` false so the next caller retries
|
||||||
@@ -64,4 +77,9 @@ export const useLinearAuthStore = create<LinearAuthStore>((set, get) => ({
|
|||||||
|
|
||||||
return inFlightAuthRefresh;
|
return inFlightAuthRefresh;
|
||||||
},
|
},
|
||||||
|
resetForRuntimeSwitch: () => {
|
||||||
|
authGeneration += 1;
|
||||||
|
inFlightAuthRefresh = null;
|
||||||
|
set({ status: null, isLoading: false, hasChecked: false });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ type RefreshOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureFreshInFlight = new Map<string, Promise<void>>();
|
const ensureFreshInFlight = new Map<string, Promise<void>>();
|
||||||
|
// Bumped on every runtime switch. Status is keyed by directory alone and two
|
||||||
|
// instances can hold the same project path, so a request already in flight for
|
||||||
|
// the previous instance would otherwise write its servers over the new one's.
|
||||||
|
let mcpGeneration = 0;
|
||||||
|
|
||||||
type TestConnectionResult = {
|
type TestConnectionResult = {
|
||||||
status?: McpStatus;
|
status?: McpStatus;
|
||||||
@@ -91,6 +95,12 @@ interface McpStore {
|
|||||||
completeAuth: (name: string, code: string, directory?: string | null) => Promise<void>;
|
completeAuth: (name: string, code: string, directory?: string | null) => Promise<void>;
|
||||||
clearAuth: (name: string, directory?: string | null) => Promise<void>;
|
clearAuth: (name: string, directory?: string | null) => Promise<void>;
|
||||||
testConnection: (name: string, directory?: string | null) => Promise<TestConnectionResult>;
|
testConnection: (name: string, directory?: string | null) => Promise<TestConnectionResult>;
|
||||||
|
/**
|
||||||
|
* MCP status is keyed by directory alone, and two instances can hold the same
|
||||||
|
* project path — so on a switch the previous instance's servers would be
|
||||||
|
* reported for the new one. Drop everything and let consumers re-ask.
|
||||||
|
*/
|
||||||
|
resetForRuntimeSwitch: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMcpStore = create<McpStore>()(
|
export const useMcpStore = create<McpStore>()(
|
||||||
@@ -101,6 +111,18 @@ export const useMcpStore = create<McpStore>()(
|
|||||||
lastErrorKeys: {},
|
lastErrorKeys: {},
|
||||||
refreshedAtKeys: {},
|
refreshedAtKeys: {},
|
||||||
|
|
||||||
|
resetForRuntimeSwitch: () => {
|
||||||
|
mcpGeneration += 1;
|
||||||
|
ensureFreshInFlight.clear();
|
||||||
|
set({
|
||||||
|
byDirectory: {},
|
||||||
|
diagnosticsByDirectory: {},
|
||||||
|
loadingKeys: {},
|
||||||
|
lastErrorKeys: {},
|
||||||
|
refreshedAtKeys: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
getStatusForDirectory: (directory) => {
|
getStatusForDirectory: (directory) => {
|
||||||
const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory);
|
const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||||
return get().byDirectory[key] ?? EMPTY_STATUS;
|
return get().byDirectory[key] ?? EMPTY_STATUS;
|
||||||
@@ -127,9 +149,11 @@ export const useMcpStore = create<McpStore>()(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generation = mcpGeneration;
|
||||||
try {
|
try {
|
||||||
const api = getMcpApiClient(directory);
|
const api = getMcpApiClient(directory);
|
||||||
const result = await api.mcp.status();
|
const result = await api.mcp.status();
|
||||||
|
if (generation !== mcpGeneration) return;
|
||||||
const data = (result.data ?? {}) as McpStatusMap;
|
const data = (result.data ?? {}) as McpStatusMap;
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -145,6 +169,7 @@ export const useMcpStore = create<McpStore>()(
|
|||||||
refreshedAtKeys: { ...state.refreshedAtKeys, [key]: Date.now() },
|
refreshedAtKeys: { ...state.refreshedAtKeys, [key]: Date.now() },
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== mcpGeneration) return;
|
||||||
const message = error instanceof Error ? error.message : 'Failed to load MCP status';
|
const message = error instanceof Error ? error.message : 'Failed to load MCP status';
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
loadingKeys: { ...state.loadingKeys, [key]: false },
|
loadingKeys: { ...state.loadingKeys, [key]: false },
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
|
import type { ProviderResult } from "@/types"
|
||||||
|
|
||||||
|
let runtimeKey = "url:https://instance-a"
|
||||||
|
let isInitialized = true
|
||||||
|
const fetched: string[] = []
|
||||||
|
|
||||||
|
type StubPayload = { usageDropdownProviders: string[] } | ProviderResult
|
||||||
|
let quotaRequestsFail = false;
|
||||||
|
const json = (body: StubPayload) => new Response(
|
||||||
|
JSON.stringify(body),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spread the real modules so the overrides stay a patch: `mock.module` is
|
||||||
|
// process-global, and a partial replacement would break every other module
|
||||||
|
// that imports something else from these files.
|
||||||
|
const runtimeSwitch = await import("@/lib/runtime-switch")
|
||||||
|
mock.module("@/lib/runtime-switch", () => ({ ...runtimeSwitch, getRuntimeKey: () => runtimeKey }))
|
||||||
|
|
||||||
|
const runtimeFetchModule = await import("@/lib/runtime-fetch")
|
||||||
|
mock.module("@/lib/runtime-fetch", () => ({
|
||||||
|
...runtimeFetchModule,
|
||||||
|
runtimeFetch: async (path: string) => {
|
||||||
|
fetched.push(path)
|
||||||
|
if (quotaRequestsFail) throw new Error("network down")
|
||||||
|
if (path.startsWith("/api/config/settings")) return json({ usageDropdownProviders: ["claude"] })
|
||||||
|
return json({ providerId: "claude", providerName: "Claude", ok: true, configured: true, usage: null, fetchedAt: 1 })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const configStoreModule = await import("@/stores/useConfigStore")
|
||||||
|
mock.module("@/stores/useConfigStore", () => ({
|
||||||
|
...configStoreModule,
|
||||||
|
useConfigStore: { ...configStoreModule.useConfigStore, getState: () => ({ isInitialized }) },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { useQuotaStore } = await import("./useQuotaStore")
|
||||||
|
|
||||||
|
describe("Usage quotas are loaded once per ready instance", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
runtimeKey = "url:https://instance-a"
|
||||||
|
isInitialized = true
|
||||||
|
fetched.length = 0
|
||||||
|
quotaRequestsFail = false
|
||||||
|
useQuotaStore.getState().resetForRuntimeSwitch()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("nothing is fetched while the instance has not reported itself initialised", async () => {
|
||||||
|
isInitialized = false
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
|
||||||
|
expect(fetched).toHaveLength(0)
|
||||||
|
expect(useQuotaStore.getState().loadedRuntimeKey).toBeNull()
|
||||||
|
|
||||||
|
// The instance finishes starting up: the same call now performs the load
|
||||||
|
// that a mount-time fetch would have answered "nothing configured".
|
||||||
|
isInitialized = true
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
|
||||||
|
expect(fetched.length).toBeGreaterThan(0)
|
||||||
|
expect(useQuotaStore.getState().results.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a second ask for the same instance does not refetch", async () => {
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
const afterFirst = fetched.length
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
|
||||||
|
expect(fetched.length).toBe(afterFirst)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a switch drops the previous instance's quotas and reloads for the new one", async () => {
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
expect(useQuotaStore.getState().results.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
useQuotaStore.getState().resetForRuntimeSwitch()
|
||||||
|
expect(useQuotaStore.getState().results).toEqual([])
|
||||||
|
expect(useQuotaStore.getState().lastUpdated).toBeNull()
|
||||||
|
|
||||||
|
runtimeKey = "url:https://instance-b"
|
||||||
|
fetched.length = 0
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
|
||||||
|
expect(fetched.length).toBeGreaterThan(0)
|
||||||
|
expect(useQuotaStore.getState().loadedRuntimeKey).toBe("url:https://instance-b")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a quota still in flight for the previous instance cannot land in the new one", async () => {
|
||||||
|
const pending = useQuotaStore.getState().fetchProviderQuota("claude")
|
||||||
|
useQuotaStore.getState().resetForRuntimeSwitch()
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(useQuotaStore.getState().results).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a transient runtime key loads nothing", async () => {
|
||||||
|
runtimeKey = "mobile-disconnected"
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
|
||||||
|
expect(fetched).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a failed load is not recorded as loaded, so the next ask retries it", async () => {
|
||||||
|
quotaRequestsFail = true
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
|
||||||
|
expect(useQuotaStore.getState().loadedRuntimeKey).toBeNull()
|
||||||
|
|
||||||
|
quotaRequestsFail = false
|
||||||
|
fetched.length = 0
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
|
||||||
|
expect(fetched.length).toBeGreaterThan(0)
|
||||||
|
expect(useQuotaStore.getState().loadedRuntimeKey).toBe("url:https://instance-a")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("concurrent asks share one load", async () => {
|
||||||
|
await Promise.all([
|
||||||
|
useQuotaStore.getState().ensureLoadedForRuntime(),
|
||||||
|
useQuotaStore.getState().ensureLoadedForRuntime(),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(fetched.filter((path) => path.startsWith("/api/quota/"))).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a switch drops the previous instance's display settings", async () => {
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
expect(useQuotaStore.getState().dropdownProviderIds).toEqual(["claude"])
|
||||||
|
useQuotaStore.getState().setDisplayMode("remaining")
|
||||||
|
|
||||||
|
useQuotaStore.getState().resetForRuntimeSwitch()
|
||||||
|
|
||||||
|
// `dropdownProviderIds` decides which providers get queried, so carrying it
|
||||||
|
// over would ask the new instance through the old one's selection.
|
||||||
|
expect(useQuotaStore.getState().dropdownProviderIds.length).toBeGreaterThan(1)
|
||||||
|
expect(useQuotaStore.getState().displayMode).toBe("usage")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,8 +8,15 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|||||||
import { getDefaultModels } from '@/lib/quota/model-families';
|
import { getDefaultModels } from '@/lib/quota/model-families';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
|
import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
|
||||||
const QUOTA_REFRESH_INTERVAL_MS = 3 * 60 * 1000;
|
const QUOTA_REFRESH_INTERVAL_MS = 3 * 60 * 1000;
|
||||||
|
// Quotas and their display settings are read from the connected OpenChamber
|
||||||
|
// instance, so both belong to that instance. Bumped on every reset so a
|
||||||
|
// response in flight for the previous instance cannot land in the new one.
|
||||||
|
let quotaGeneration = 0;
|
||||||
|
let inFlightRuntimeLoad: Promise<void> | null = null;
|
||||||
let quotaAutoRefreshConsumers = 0;
|
let quotaAutoRefreshConsumers = 0;
|
||||||
let quotaAutoRefreshInterval: number | null = null;
|
let quotaAutoRefreshInterval: number | null = null;
|
||||||
|
|
||||||
@@ -22,6 +29,8 @@ interface QuotaSettingsState {
|
|||||||
|
|
||||||
interface QuotaStore extends QuotaSettingsState {
|
interface QuotaStore extends QuotaSettingsState {
|
||||||
results: ProviderResult[];
|
results: ProviderResult[];
|
||||||
|
/** Instance whose quotas `results` describes, or `null` when nothing is loaded. */
|
||||||
|
loadedRuntimeKey: string | null;
|
||||||
selectedProviderId: QuotaProviderId | null;
|
selectedProviderId: QuotaProviderId | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetchingProvider: Record<string, boolean>;
|
isFetchingProvider: Record<string, boolean>;
|
||||||
@@ -30,8 +39,10 @@ interface QuotaStore extends QuotaSettingsState {
|
|||||||
|
|
||||||
loadSettings: () => Promise<void>;
|
loadSettings: () => Promise<void>;
|
||||||
fetchAllQuotas: () => Promise<void>;
|
fetchAllQuotas: () => Promise<void>;
|
||||||
fetchQuotas: (providerIds: QuotaProviderId[]) => Promise<void>;
|
/** Resolves true when at least one provider answered — see `ensureLoadedForRuntime`. */
|
||||||
fetchProviderQuota: (providerId: QuotaProviderId) => Promise<void>;
|
fetchQuotas: (providerIds: QuotaProviderId[]) => Promise<boolean>;
|
||||||
|
/** Resolves true when the instance answered, false on a transport failure. */
|
||||||
|
fetchProviderQuota: (providerId: QuotaProviderId) => Promise<boolean>;
|
||||||
setSelectedProvider: (providerId: QuotaProviderId | null) => void;
|
setSelectedProvider: (providerId: QuotaProviderId | null) => void;
|
||||||
setDisplayMode: (mode: 'usage' | 'remaining') => void;
|
setDisplayMode: (mode: 'usage' | 'remaining') => void;
|
||||||
setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void;
|
setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void;
|
||||||
@@ -40,6 +51,18 @@ interface QuotaStore extends QuotaSettingsState {
|
|||||||
setExpandedFamilies: (providerId: string, familyIds: string[]) => void;
|
setExpandedFamilies: (providerId: string, familyIds: string[]) => void;
|
||||||
toggleFamilyExpanded: (providerId: string, familyId: string) => void;
|
toggleFamilyExpanded: (providerId: string, familyId: string) => void;
|
||||||
applyDefaultSelections: (providerId: string, availableModels: string[]) => void;
|
applyDefaultSelections: (providerId: string, availableModels: string[]) => void;
|
||||||
|
/**
|
||||||
|
* Load settings and quotas once per instance, when that instance is ready.
|
||||||
|
*
|
||||||
|
* Providers report themselves as configured only after the instance can read
|
||||||
|
* their credentials, which on a remote instance is not true the moment the UI
|
||||||
|
* mounts. A fetch fired at mount therefore answers "nothing configured", and
|
||||||
|
* because every provider then has a result, no consumer asks again until the
|
||||||
|
* three-minute refresh — which is why Usage stayed missing from the
|
||||||
|
* work-status panel until Settings -> Usage forced a fresh fetch.
|
||||||
|
*/
|
||||||
|
ensureLoadedForRuntime: () => Promise<void>;
|
||||||
|
resetForRuntimeSwitch: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState => {
|
const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState => {
|
||||||
@@ -84,6 +107,13 @@ const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultQuotaSettings = (): QuotaSettingsState => ({
|
||||||
|
displayMode: 'usage',
|
||||||
|
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
|
||||||
|
selectedModels: {},
|
||||||
|
expandedFamilies: {},
|
||||||
|
});
|
||||||
|
|
||||||
const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
|
const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
|
||||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||||
if (runtimeSettings) {
|
if (runtimeSettings) {
|
||||||
@@ -107,18 +137,14 @@ const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return defaultQuotaSettings();
|
||||||
displayMode: 'usage',
|
|
||||||
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
|
|
||||||
selectedModels: {},
|
|
||||||
expandedFamilies: {},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useQuotaStore = create<QuotaStore>()(
|
export const useQuotaStore = create<QuotaStore>()(
|
||||||
devtools(
|
devtools(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
results: [],
|
results: [],
|
||||||
|
loadedRuntimeKey: null,
|
||||||
selectedProviderId: null,
|
selectedProviderId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isFetchingProvider: {},
|
isFetchingProvider: {},
|
||||||
@@ -130,8 +156,10 @@ export const useQuotaStore = create<QuotaStore>()(
|
|||||||
expandedFamilies: {},
|
expandedFamilies: {},
|
||||||
|
|
||||||
loadSettings: async () => {
|
loadSettings: async () => {
|
||||||
|
const generation = quotaGeneration;
|
||||||
try {
|
try {
|
||||||
const settings = await loadSettingsFromRuntime();
|
const settings = await loadSettingsFromRuntime();
|
||||||
|
if (generation !== quotaGeneration) return;
|
||||||
set(settings);
|
set(settings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to load usage settings:', error);
|
console.warn('Failed to load usage settings:', error);
|
||||||
@@ -139,18 +167,23 @@ export const useQuotaStore = create<QuotaStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchQuotas: async (providerIds) => {
|
fetchQuotas: async (providerIds) => {
|
||||||
|
const generation = quotaGeneration;
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
const answered = await Promise.all(
|
||||||
providerIds.map((providerId) => get().fetchProviderQuota(providerId))
|
providerIds.map((providerId) => get().fetchProviderQuota(providerId))
|
||||||
);
|
);
|
||||||
|
if (generation !== quotaGeneration) return false;
|
||||||
set({
|
set({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
lastUpdated: Date.now()
|
lastUpdated: Date.now()
|
||||||
});
|
});
|
||||||
|
return answered.some(Boolean);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== quotaGeneration) return false;
|
||||||
const message = error instanceof Error ? error.message : 'Failed to fetch quotas';
|
const message = error instanceof Error ? error.message : 'Failed to fetch quotas';
|
||||||
set({ isLoading: false, error: message });
|
set({ isLoading: false, error: message });
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -159,6 +192,7 @@ export const useQuotaStore = create<QuotaStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchProviderQuota: async (providerId) => {
|
fetchProviderQuota: async (providerId) => {
|
||||||
|
const generation = quotaGeneration;
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true }
|
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true }
|
||||||
}));
|
}));
|
||||||
@@ -169,13 +203,16 @@ export const useQuotaStore = create<QuotaStore>()(
|
|||||||
throw new Error(payload?.error || 'Failed to fetch quota');
|
throw new Error(payload?.error || 'Failed to fetch quota');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (generation !== quotaGeneration) return false;
|
||||||
const result = payload as ProviderResult;
|
const result = payload as ProviderResult;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next = state.results.filter((entry) => entry.providerId !== providerId);
|
const next = state.results.filter((entry) => entry.providerId !== providerId);
|
||||||
next.push(result);
|
next.push(result);
|
||||||
return { results: next, error: null };
|
return { results: next, error: null };
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== quotaGeneration) return false;
|
||||||
const message = error instanceof Error ? error.message : 'Failed to fetch quota';
|
const message = error instanceof Error ? error.message : 'Failed to fetch quota';
|
||||||
const fallback: ProviderResult = {
|
const fallback: ProviderResult = {
|
||||||
providerId,
|
providerId,
|
||||||
@@ -191,13 +228,62 @@ export const useQuotaStore = create<QuotaStore>()(
|
|||||||
next.push(fallback);
|
next.push(fallback);
|
||||||
return { results: next, error: message };
|
return { results: next, error: message };
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
set((state) => ({
|
if (generation === quotaGeneration) {
|
||||||
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false }
|
set((state) => ({
|
||||||
}));
|
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false }
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
ensureLoadedForRuntime: async () => {
|
||||||
|
const runtimeKey = getRuntimeKey();
|
||||||
|
if (isTransientRuntimeKey(runtimeKey)) return;
|
||||||
|
// Wait for the instance to report itself initialised. Asking earlier
|
||||||
|
// gets an honest-looking "not configured" for every provider, which is
|
||||||
|
// then cached as if it were the answer.
|
||||||
|
if (!useConfigStore.getState().isInitialized) return;
|
||||||
|
if (get().loadedRuntimeKey === runtimeKey) return;
|
||||||
|
if (inFlightRuntimeLoad) return inFlightRuntimeLoad;
|
||||||
|
|
||||||
|
const generation = quotaGeneration;
|
||||||
|
inFlightRuntimeLoad = (async () => {
|
||||||
|
await get().loadSettings();
|
||||||
|
if (generation !== quotaGeneration) return;
|
||||||
|
const { dropdownProviderIds, fetchQuotas } = get();
|
||||||
|
if (dropdownProviderIds.length === 0) return;
|
||||||
|
const answered = await fetchQuotas(dropdownProviderIds);
|
||||||
|
// Mark the instance loaded only once it actually answered. Claiming it
|
||||||
|
// up front meant a load that failed on a cold or briefly unreachable
|
||||||
|
// instance was never attempted again — Usage would stay empty until
|
||||||
|
// the three-minute refresh, or forever after a switch.
|
||||||
|
if (answered && generation === quotaGeneration) set({ loadedRuntimeKey: runtimeKey });
|
||||||
|
})().finally(() => { inFlightRuntimeLoad = null; });
|
||||||
|
|
||||||
|
return inFlightRuntimeLoad;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetForRuntimeSwitch: () => {
|
||||||
|
quotaGeneration += 1;
|
||||||
|
inFlightRuntimeLoad = null;
|
||||||
|
set({
|
||||||
|
// Display mode, the provider selection and the per-provider model
|
||||||
|
// picks all come from the instance's own settings, and
|
||||||
|
// `dropdownProviderIds` decides what gets fetched — carrying them
|
||||||
|
// over would query the new instance through the old one's choices.
|
||||||
|
...defaultQuotaSettings(),
|
||||||
|
results: [],
|
||||||
|
loadedRuntimeKey: null,
|
||||||
|
selectedProviderId: null,
|
||||||
|
isLoading: false,
|
||||||
|
isFetchingProvider: {},
|
||||||
|
lastUpdated: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
setSelectedProvider: (providerId) => set({ selectedProviderId: providerId }),
|
setSelectedProvider: (providerId) => set({ selectedProviderId: providerId }),
|
||||||
setDisplayMode: (mode) => set({ displayMode: mode }),
|
setDisplayMode: (mode) => set({ displayMode: mode }),
|
||||||
setDropdownProviderIds: (providerIds) => set({ dropdownProviderIds: providerIds }),
|
setDropdownProviderIds: (providerIds) => set({ dropdownProviderIds: providerIds }),
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ interface SkillsStore {
|
|||||||
renameSkill: (name: string, newName: string, directory?: string | null) => Promise<boolean>;
|
renameSkill: (name: string, newName: string, directory?: string | null) => Promise<boolean>;
|
||||||
deleteSkill: (name: string, directory?: string | null) => Promise<boolean>;
|
deleteSkill: (name: string, directory?: string | null) => Promise<boolean>;
|
||||||
getSkillByName: (name: string, directory?: string | null) => DiscoveredSkill | undefined;
|
getSkillByName: (name: string, directory?: string | null) => DiscoveredSkill | undefined;
|
||||||
|
/**
|
||||||
|
* Skills are discovered on the connected instance and cached by directory,
|
||||||
|
* which two instances can share — so a switch must drop the caches rather
|
||||||
|
* than report the previous instance's skills for the new one.
|
||||||
|
*/
|
||||||
|
resetForRuntimeSwitch: () => void;
|
||||||
|
|
||||||
// Supporting files
|
// Supporting files
|
||||||
readSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise<string | null>;
|
readSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise<string | null>;
|
||||||
@@ -192,6 +198,10 @@ const SKILLS_LOAD_CACHE_TTL_MS = 5000;
|
|||||||
const DEFAULT_SKILLS_CACHE_KEY = '__default__';
|
const DEFAULT_SKILLS_CACHE_KEY = '__default__';
|
||||||
const skillsLastLoadedAt = new Map<string, number>();
|
const skillsLastLoadedAt = new Map<string, number>();
|
||||||
const skillsLoadInFlight = new Map<string, Promise<boolean>>();
|
const skillsLoadInFlight = new Map<string, Promise<boolean>>();
|
||||||
|
// Bumped on every runtime switch. Skills are discovered on the connected
|
||||||
|
// instance and cached by directory, which two instances can share, so a load
|
||||||
|
// already in flight for the previous instance must not write into the new one.
|
||||||
|
let skillsGeneration = 0;
|
||||||
|
|
||||||
const getSkillsCacheKey = (directory: string | null): string => {
|
const getSkillsCacheKey = (directory: string | null): string => {
|
||||||
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
|
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
|
||||||
@@ -279,6 +289,13 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
skillDraft: null,
|
skillDraft: null,
|
||||||
|
|
||||||
|
resetForRuntimeSwitch: () => {
|
||||||
|
skillsGeneration += 1;
|
||||||
|
skillsLastLoadedAt.clear();
|
||||||
|
skillsLoadInFlight.clear();
|
||||||
|
set({ skills: [], skillsByDirectory: {}, isLoading: false });
|
||||||
|
},
|
||||||
|
|
||||||
setSelectedSkill: (name: string | null) => {
|
setSelectedSkill: (name: string | null) => {
|
||||||
set({ selectedSkillName: name });
|
set({ selectedSkillName: name });
|
||||||
},
|
},
|
||||||
@@ -304,6 +321,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
return inFlight;
|
return inFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generation = skillsGeneration;
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
// Failure must never look like an empty project. The mirror is the
|
// Failure must never look like an empty project. The mirror is the
|
||||||
@@ -349,6 +367,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
data.externalSkills ?? null,
|
data.externalSkills ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (generation !== skillsGeneration) return false;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next: Partial<SkillsStore> = {
|
const next: Partial<SkillsStore> = {
|
||||||
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills },
|
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills },
|
||||||
@@ -367,6 +386,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error("Failed to load skills:", lastError);
|
console.error("Failed to load skills:", lastError);
|
||||||
|
if (generation !== skillsGeneration) return false;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next: Partial<SkillsStore> = {
|
const next: Partial<SkillsStore> = {
|
||||||
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills },
|
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills },
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||||
import { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } from './useUIStore';
|
|
||||||
|
let runtimeKey = 'url:https://instance-a';
|
||||||
|
const runtimeSwitch = await import('@/lib/runtime-switch');
|
||||||
|
mock.module('@/lib/runtime-switch', () => ({ ...runtimeSwitch, getRuntimeKey: () => runtimeKey }));
|
||||||
|
|
||||||
|
const { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } = await import('./useUIStore');
|
||||||
|
|
||||||
describe('linear issue list filters', () => {
|
describe('linear issue list filters', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
runtimeKey = 'url:https://instance-a';
|
||||||
useUIStore.setState({
|
useUIStore.setState({
|
||||||
linearIssueListStatus: 'all',
|
linearIssueListStatus: 'all',
|
||||||
linearIssueListAssignee: 'any',
|
linearIssueListAssignee: 'any',
|
||||||
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
|
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
|
||||||
|
linearIssueListTeamIdByRuntime: {},
|
||||||
linearIssueListPriority: 'all',
|
linearIssueListPriority: 'all',
|
||||||
linearIssueFocus: null,
|
linearIssueFocus: null,
|
||||||
});
|
});
|
||||||
@@ -67,4 +74,43 @@ describe('linear issue list filters', () => {
|
|||||||
useUIStore.getState().setLinearIssueFocus(null);
|
useUIStore.getState().setLinearIssueFocus(null);
|
||||||
expect(useUIStore.getState().linearIssueFocus).toBeNull();
|
expect(useUIStore.getState().linearIssueFocus).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps the team filter with the instance that owns the workspace', () => {
|
||||||
|
useUIStore.getState().setLinearIssueListTeamId('team-eng');
|
||||||
|
expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng');
|
||||||
|
|
||||||
|
// Switching instances: a team belongs to one Linear workspace, so the new
|
||||||
|
// instance opens on all teams rather than on a filter matching nothing.
|
||||||
|
runtimeKey = 'url:https://instance-b';
|
||||||
|
useUIStore.getState().applyLinearIssueListFiltersForRuntime();
|
||||||
|
expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS);
|
||||||
|
|
||||||
|
useUIStore.getState().setLinearIssueListTeamId('team-ops');
|
||||||
|
expect(useUIStore.getState().linearIssueListTeamId).toBe('team-ops');
|
||||||
|
|
||||||
|
// Switching back restores the first instance's own choice.
|
||||||
|
runtimeKey = 'url:https://instance-a';
|
||||||
|
useUIStore.getState().applyLinearIssueListFiltersForRuntime();
|
||||||
|
expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a transient runtime key stores nothing and reads as all teams', () => {
|
||||||
|
runtimeKey = 'mobile-disconnected';
|
||||||
|
useUIStore.getState().setLinearIssueListTeamId('team-eng');
|
||||||
|
|
||||||
|
expect(useUIStore.getState().linearIssueListTeamIdByRuntime).toEqual({});
|
||||||
|
|
||||||
|
useUIStore.getState().applyLinearIssueListFiltersForRuntime();
|
||||||
|
expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resetting filters clears the stored team for this instance only', () => {
|
||||||
|
useUIStore.getState().setLinearIssueListTeamId('team-eng');
|
||||||
|
runtimeKey = 'url:https://instance-b';
|
||||||
|
useUIStore.getState().setLinearIssueListTeamId('team-ops');
|
||||||
|
|
||||||
|
useUIStore.getState().resetLinearIssueListFilters();
|
||||||
|
|
||||||
|
expect(useUIStore.getState().linearIssueListTeamIdByRuntime).toEqual({ 'url:https://instance-a': 'team-eng' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { ProjectRef } from '@/lib/projectContextApi';
|
|||||||
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
|
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
|
||||||
import { isWindowsArm64 } from '@/lib/platform';
|
import { isWindowsArm64 } from '@/lib/platform';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
|
||||||
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
|
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
|
||||||
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal';
|
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal';
|
||||||
@@ -65,6 +66,38 @@ function sanitizeLinearIssueListTeamId(value: unknown): string {
|
|||||||
return teamId || LINEAR_ISSUE_LIST_ALL_TEAMS;
|
return teamId || LINEAR_ISSUE_LIST_ALL_TEAMS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the team filter under the connected instance, dropping the entry when
|
||||||
|
* it falls back to all teams so the map does not accumulate defaults. Transient
|
||||||
|
* keys (uninitialised, mobile-disconnected) name no instance and are not written.
|
||||||
|
*/
|
||||||
|
function writeLinearTeamIdForRuntime(
|
||||||
|
entries: Record<string, string>,
|
||||||
|
teamId: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const runtimeKey = getRuntimeKey();
|
||||||
|
if (isTransientRuntimeKey(runtimeKey)) return entries;
|
||||||
|
const next = { ...entries };
|
||||||
|
if (teamId === LINEAR_ISSUE_LIST_ALL_TEAMS) {
|
||||||
|
delete next[runtimeKey];
|
||||||
|
} else {
|
||||||
|
next[runtimeKey] = teamId;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeLinearIssueListTeamIdByRuntime(value: unknown): Record<string, string> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||||
|
const entries: Record<string, string> = {};
|
||||||
|
// SAFETY: guarded above as a non-array object; every value is re-checked below.
|
||||||
|
for (const [runtimeKey, teamId] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (!runtimeKey.trim() || typeof teamId !== 'string') continue;
|
||||||
|
const sanitized = sanitizeLinearIssueListTeamId(teamId);
|
||||||
|
if (sanitized !== LINEAR_ISSUE_LIST_ALL_TEAMS) entries[runtimeKey] = sanitized;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeLinearIssueListPriority(value: unknown): LinearIssueListPriority {
|
function sanitizeLinearIssueListPriority(value: unknown): LinearIssueListPriority {
|
||||||
return value === 'none' || value === 'urgent' || value === 'high' || value === 'medium' || value === 'low' || value === 'all'
|
return value === 'none' || value === 'urgent' || value === 'high' || value === 'medium' || value === 'low' || value === 'all'
|
||||||
? value
|
? value
|
||||||
@@ -820,7 +853,16 @@ interface UIStore {
|
|||||||
gitChangesViewMode: 'flat' | 'tree';
|
gitChangesViewMode: 'flat' | 'tree';
|
||||||
linearIssueListStatus: LinearIssueListStatus;
|
linearIssueListStatus: LinearIssueListStatus;
|
||||||
linearIssueListAssignee: LinearIssueListAssignee;
|
linearIssueListAssignee: LinearIssueListAssignee;
|
||||||
|
/**
|
||||||
|
* Team filter for the instance currently connected. A Linear team belongs to
|
||||||
|
* one workspace, and each OpenChamber instance has its own Linear login, so
|
||||||
|
* this is derived from `linearIssueListTeamIdByRuntime` rather than persisted
|
||||||
|
* on its own — a team id carried across a switch filters the new instance's
|
||||||
|
* list down to nothing.
|
||||||
|
*/
|
||||||
linearIssueListTeamId: string;
|
linearIssueListTeamId: string;
|
||||||
|
/** Team filter per instance, keyed the same way every runtime-scoped cache is. */
|
||||||
|
linearIssueListTeamIdByRuntime: Record<string, string>;
|
||||||
linearIssueListPriority: LinearIssueListPriority;
|
linearIssueListPriority: LinearIssueListPriority;
|
||||||
/** One-shot identifier for opening a Linear issue in the rail panel. Not persisted. */
|
/** One-shot identifier for opening a Linear issue in the rail panel. Not persisted. */
|
||||||
linearIssueFocus: string | null;
|
linearIssueFocus: string | null;
|
||||||
@@ -1023,6 +1065,8 @@ interface UIStore {
|
|||||||
setLinearIssueListStatus: (status: LinearIssueListStatus) => void;
|
setLinearIssueListStatus: (status: LinearIssueListStatus) => void;
|
||||||
setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void;
|
setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void;
|
||||||
setLinearIssueListTeamId: (teamId: string) => void;
|
setLinearIssueListTeamId: (teamId: string) => void;
|
||||||
|
/** Re-read the team filter for the instance now connected. */
|
||||||
|
applyLinearIssueListFiltersForRuntime: () => void;
|
||||||
setLinearIssueListPriority: (priority: LinearIssueListPriority) => void;
|
setLinearIssueListPriority: (priority: LinearIssueListPriority) => void;
|
||||||
resetLinearIssueListFilters: () => void;
|
resetLinearIssueListFilters: () => void;
|
||||||
setLinearIssueFocus: (identifier: string | null) => void;
|
setLinearIssueFocus: (identifier: string | null) => void;
|
||||||
@@ -1186,6 +1230,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
linearIssueListStatus: 'all',
|
linearIssueListStatus: 'all',
|
||||||
linearIssueListAssignee: 'any',
|
linearIssueListAssignee: 'any',
|
||||||
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
|
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
|
||||||
|
linearIssueListTeamIdByRuntime: {},
|
||||||
linearIssueListPriority: 'all',
|
linearIssueListPriority: 'all',
|
||||||
linearIssueFocus: null,
|
linearIssueFocus: null,
|
||||||
isTimelineDialogOpen: false,
|
isTimelineDialogOpen: false,
|
||||||
@@ -2113,7 +2158,20 @@ export const useUIStore = create<UIStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setLinearIssueListTeamId: (teamId) => {
|
setLinearIssueListTeamId: (teamId) => {
|
||||||
set({ linearIssueListTeamId: sanitizeLinearIssueListTeamId(teamId) });
|
const sanitized = sanitizeLinearIssueListTeamId(teamId);
|
||||||
|
set((state) => ({
|
||||||
|
linearIssueListTeamId: sanitized,
|
||||||
|
linearIssueListTeamIdByRuntime: writeLinearTeamIdForRuntime(state.linearIssueListTeamIdByRuntime, sanitized),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
applyLinearIssueListFiltersForRuntime: () => {
|
||||||
|
const runtimeKey = getRuntimeKey();
|
||||||
|
set((state) => ({
|
||||||
|
linearIssueListTeamId: isTransientRuntimeKey(runtimeKey)
|
||||||
|
? LINEAR_ISSUE_LIST_ALL_TEAMS
|
||||||
|
: state.linearIssueListTeamIdByRuntime[runtimeKey] ?? LINEAR_ISSUE_LIST_ALL_TEAMS,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
setLinearIssueListPriority: (priority) => {
|
setLinearIssueListPriority: (priority) => {
|
||||||
@@ -2121,12 +2179,16 @@ export const useUIStore = create<UIStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
resetLinearIssueListFilters: () => {
|
resetLinearIssueListFilters: () => {
|
||||||
set({
|
set((state) => ({
|
||||||
linearIssueListStatus: 'all',
|
linearIssueListStatus: 'all',
|
||||||
linearIssueListAssignee: 'any',
|
linearIssueListAssignee: 'any',
|
||||||
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
|
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
|
||||||
|
linearIssueListTeamIdByRuntime: writeLinearTeamIdForRuntime(
|
||||||
|
state.linearIssueListTeamIdByRuntime,
|
||||||
|
LINEAR_ISSUE_LIST_ALL_TEAMS,
|
||||||
|
),
|
||||||
linearIssueListPriority: 'all',
|
linearIssueListPriority: 'all',
|
||||||
});
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
setLinearIssueFocus: (identifier) => {
|
setLinearIssueFocus: (identifier) => {
|
||||||
@@ -2581,7 +2643,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
{
|
{
|
||||||
name: 'ui-store',
|
name: 'ui-store',
|
||||||
storage: createDeferredSafeJSONStorage(),
|
storage: createDeferredSafeJSONStorage(),
|
||||||
version: 18,
|
version: 19,
|
||||||
migrate: (persistedState, version) => {
|
migrate: (persistedState, version) => {
|
||||||
if (!persistedState || typeof persistedState !== 'object') {
|
if (!persistedState || typeof persistedState !== 'object') {
|
||||||
return persistedState;
|
return persistedState;
|
||||||
@@ -2792,7 +2854,11 @@ export const useUIStore = create<UIStore>()(
|
|||||||
|
|
||||||
state.linearIssueListStatus = sanitizeLinearIssueListStatus(state.linearIssueListStatus);
|
state.linearIssueListStatus = sanitizeLinearIssueListStatus(state.linearIssueListStatus);
|
||||||
state.linearIssueListAssignee = sanitizeLinearIssueListAssignee(state.linearIssueListAssignee);
|
state.linearIssueListAssignee = sanitizeLinearIssueListAssignee(state.linearIssueListAssignee);
|
||||||
state.linearIssueListTeamId = sanitizeLinearIssueListTeamId(state.linearIssueListTeamId);
|
// v18 -> v19: the team filter became per instance. The legacy flat
|
||||||
|
// value names a team in one workspace with nothing to say which
|
||||||
|
// instance it came from, so it is dropped rather than guessed at.
|
||||||
|
delete state.linearIssueListTeamId;
|
||||||
|
state.linearIssueListTeamIdByRuntime = sanitizeLinearIssueListTeamIdByRuntime(state.linearIssueListTeamIdByRuntime);
|
||||||
state.linearIssueListPriority = sanitizeLinearIssueListPriority(state.linearIssueListPriority);
|
state.linearIssueListPriority = sanitizeLinearIssueListPriority(state.linearIssueListPriority);
|
||||||
|
|
||||||
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
|
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
|
||||||
@@ -2874,7 +2940,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
gitChangesViewMode: state.gitChangesViewMode,
|
gitChangesViewMode: state.gitChangesViewMode,
|
||||||
linearIssueListStatus: state.linearIssueListStatus,
|
linearIssueListStatus: state.linearIssueListStatus,
|
||||||
linearIssueListAssignee: state.linearIssueListAssignee,
|
linearIssueListAssignee: state.linearIssueListAssignee,
|
||||||
linearIssueListTeamId: state.linearIssueListTeamId,
|
linearIssueListTeamIdByRuntime: state.linearIssueListTeamIdByRuntime,
|
||||||
linearIssueListPriority: state.linearIssueListPriority,
|
linearIssueListPriority: state.linearIssueListPriority,
|
||||||
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
||||||
notificationMode: state.notificationMode,
|
notificationMode: state.notificationMode,
|
||||||
|
|||||||
@@ -186,7 +186,11 @@ mock.module("../selection-store", () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Spread the real module so the stub stays a patch: anything else importing
|
||||||
|
// runtime-switch in this process still gets its remaining exports.
|
||||||
|
const runtimeSwitchModule = await import("@/lib/runtime-switch")
|
||||||
mock.module("@/lib/runtime-switch", () => ({
|
mock.module("@/lib/runtime-switch", () => ({
|
||||||
|
...runtimeSwitchModule,
|
||||||
getRuntimeApiBaseUrl: () => "",
|
getRuntimeApiBaseUrl: () => "",
|
||||||
getRuntimeKey: () => "test-runtime",
|
getRuntimeKey: () => "test-runtime",
|
||||||
initializeRuntimeEndpoint: () => undefined,
|
initializeRuntimeEndpoint: () => undefined,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type QuotaProviderId =
|
|||||||
| 'opencode-go'
|
| 'opencode-go'
|
||||||
| 'crof'
|
| 'crof'
|
||||||
| 'deepseek'
|
| 'deepseek'
|
||||||
|
| 'exe-dev'
|
||||||
| 'neuralwatt'
|
| 'neuralwatt'
|
||||||
| 'xai';
|
| 'xai';
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
@@ -1,6 +1,7 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
__openchamberEnsureNerdFonts?: () => Promise<void>;
|
||||||
__opencodeDebug?: {
|
__opencodeDebug?: {
|
||||||
getLastAssistantMessage: () => unknown;
|
getLastAssistantMessage: () => unknown;
|
||||||
getAllMessages: (truncate?: boolean) => unknown[];
|
getAllMessages: (truncate?: boolean) => unknown[];
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews
|
|||||||
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
||||||
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
|
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
|
||||||
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API).
|
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API).
|
||||||
|
- Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider.
|
||||||
|
|
||||||
- `opencode-upgrade-runtime.ts`
|
- `opencode-upgrade-runtime.ts`
|
||||||
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
|
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput
|
|||||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'api:git/branch-push-status': {
|
||||||
|
const { directory, branches } = (payload || {}) as { directory?: string; branches?: string[] };
|
||||||
|
const dirError = requireDirectory(id, type, directory);
|
||||||
|
if (dirError) return dirError;
|
||||||
|
if (!Array.isArray(branches) || branches.some((branch) => typeof branch !== 'string')) {
|
||||||
|
return { id, type, success: false, error: 'branches must be an array of branch names' };
|
||||||
|
}
|
||||||
|
return { id, type, success: true, data: await gitService.getGitUnpushedBranchCounts(directory!, branches) };
|
||||||
|
}
|
||||||
|
|
||||||
case 'api:git/remote-branches': {
|
case 'api:git/remote-branches': {
|
||||||
const { directory, branch, remote } = (payload || {}) as {
|
const { directory, branch, remote } = (payload || {}) as {
|
||||||
directory?: string;
|
directory?: string;
|
||||||
|
|||||||
@@ -555,7 +555,7 @@ export async function handleSystemBridgeMessage(
|
|||||||
case 'api:quota:credentials': {
|
case 'api:quota:credentials': {
|
||||||
const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown };
|
const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown };
|
||||||
try {
|
try {
|
||||||
if (!providerId || !['ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' };
|
if (!providerId || !['exe-dev', 'ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' };
|
||||||
if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) };
|
if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) };
|
||||||
if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; }
|
if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; }
|
||||||
if (method === 'IMPORT') {
|
if (method === 'IMPORT') {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fetchExeDevUsage, parseExeDevUsage } from './exeDevQuota';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
monthly_allowance_usd: 20,
|
||||||
|
period_end: '2026-10-01T00:00:00Z',
|
||||||
|
total_cost_usd: 0.11,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('exe.dev quota', () => {
|
||||||
|
it('parses monthly credit usage', () => {
|
||||||
|
const windows = parseExeDevUsage(payload);
|
||||||
|
assert.ok(windows);
|
||||||
|
assert.ok(Math.abs((windows.monthly.usedPercent ?? 0) - 0.55) < 0.0001);
|
||||||
|
assert.equal(windows.monthly.valueLabel, '$0.11 / $20.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes only the billing usage command', async () => {
|
||||||
|
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
|
const windows = await fetchExeDevUsage('test-token', async (url, init) => {
|
||||||
|
requests.push({ url: String(url), init });
|
||||||
|
return Response.json(payload);
|
||||||
|
});
|
||||||
|
assert.equal(windows.monthly.valueLabel, '$0.11 / $20.00');
|
||||||
|
assert.equal(requests[0]?.url, 'https://exe.dev/exec');
|
||||||
|
assert.equal(requests[0]?.init?.body, 'billing credits usage --group=day --json');
|
||||||
|
assert.equal(new Headers(requests[0]?.init?.headers).get('Authorization'), 'Bearer test-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
type ExeDevUsageWindow = {
|
||||||
|
usedPercent: number | null;
|
||||||
|
remainingPercent: number | null;
|
||||||
|
windowSeconds: null;
|
||||||
|
resetAfterSeconds: number | null;
|
||||||
|
resetAt: number;
|
||||||
|
resetAtFormatted: string;
|
||||||
|
resetAfterFormatted: string | null;
|
||||||
|
valueLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExeDevUsagePayload = {
|
||||||
|
total_cost_usd?: number | null;
|
||||||
|
monthly_allowance_usd?: number | null;
|
||||||
|
period_end?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXEC_URL = 'https://exe.dev/exec';
|
||||||
|
const USAGE_COMMAND = 'billing credits usage --group=day --json';
|
||||||
|
|
||||||
|
const numberValue = (value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined || !Number.isFinite(value)) return null;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseExeDevUsage = (payload: ExeDevUsagePayload | null): Record<string, ExeDevUsageWindow> | null => {
|
||||||
|
if (!payload) return null;
|
||||||
|
const totalCost = numberValue(payload.total_cost_usd);
|
||||||
|
const monthlyAllowance = numberValue(payload.monthly_allowance_usd);
|
||||||
|
const resetAt = payload.period_end ? Date.parse(payload.period_end) : Number.NaN;
|
||||||
|
if (totalCost === null || monthlyAllowance === null || monthlyAllowance < 0 || !Number.isFinite(resetAt)) return null;
|
||||||
|
const usedPercent = monthlyAllowance > 0 ? Math.min(100, Math.max(0, (totalCost / monthlyAllowance) * 100)) : null;
|
||||||
|
const remainingPercent = usedPercent === null ? null : Math.max(0, 100 - usedPercent);
|
||||||
|
const resetAfterSeconds = Math.max(0, Math.floor((resetAt - Date.now()) / 1000));
|
||||||
|
return {
|
||||||
|
monthly: {
|
||||||
|
usedPercent,
|
||||||
|
remainingPercent,
|
||||||
|
windowSeconds: null,
|
||||||
|
resetAfterSeconds,
|
||||||
|
resetAt,
|
||||||
|
resetAtFormatted: new Date(resetAt).toLocaleString(),
|
||||||
|
resetAfterFormatted: null,
|
||||||
|
valueLabel: `$${totalCost.toFixed(2)} / $${monthlyAllowance.toFixed(2)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchExeDevUsage = async (usageToken: string, fetchImpl: typeof fetch = fetch) => {
|
||||||
|
const response = await fetchImpl(EXEC_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${usageToken}`,
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
'User-Agent': 'OpenChamber quota provider',
|
||||||
|
},
|
||||||
|
body: USAGE_COMMAND,
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) throw new Error('exe.dev authentication failed');
|
||||||
|
if (!response.ok) throw new Error(`exe.dev usage API returned HTTP ${response.status}`);
|
||||||
|
const payload: ExeDevUsagePayload | null = await response.text().then((text) => JSON.parse(text)).catch(() => null);
|
||||||
|
const windows = parseExeDevUsage(payload);
|
||||||
|
if (!windows) throw new Error('exe.dev usage data could not be parsed');
|
||||||
|
return windows;
|
||||||
|
};
|
||||||
@@ -671,6 +671,23 @@ export interface GitBranchResult {
|
|||||||
branches: Record<string, GitBranchDetails>;
|
branches: Record<string, GitBranchDetails>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGitUnpushedBranchCounts(directory: string, requestedBranches: string[]): Promise<{ counts: Record<string, number> }> {
|
||||||
|
const requested = [...new Set(requestedBranches)].filter(Boolean).slice(0, 5);
|
||||||
|
if (requested.length === 0) return { counts: {} };
|
||||||
|
const local = new Set((await getGitBranchesRaw(directory)).all.filter((branch) => !branch.startsWith('remotes/')));
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
await Promise.all(requested.map(async (branch) => {
|
||||||
|
if (!local.has(branch)) return;
|
||||||
|
const upstreamResult = await execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`], directory);
|
||||||
|
const upstream = upstreamResult.exitCode === 0 ? upstreamResult.stdout.trim() : '';
|
||||||
|
if (!upstream) return;
|
||||||
|
const countResult = await execGit(['rev-list', '--count', `${upstream}..${branch}`], directory);
|
||||||
|
const count = countResult.exitCode === 0 ? Number.parseInt(countResult.stdout.trim(), 10) : 0;
|
||||||
|
if (Number.isFinite(count) && count > 0) counts[branch] = count;
|
||||||
|
}));
|
||||||
|
return { counts };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all branches for a directory
|
* Get all branches for a directory
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const toWindow = (usedPercent: number, resetAt: string) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => {
|
export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => {
|
||||||
const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}` }, signal: AbortSignal.timeout(15_000) });
|
const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}`, 'x-opencode-session': 'openchamber-usage' }, signal: AbortSignal.timeout(15_000) });
|
||||||
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed');
|
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed');
|
||||||
if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`);
|
||||||
const payload = await response.json().catch(() => null) as { usage?: Record<string, { percent?: unknown; resetsAt?: unknown }> } | null;
|
const payload = await response.json().catch(() => null) as { usage?: Record<string, { percent?: unknown; resetsAt?: unknown }> } | null;
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { fetchExeDevUsage } from './exeDevQuota';
|
||||||
|
|
||||||
export type ManagedProvider = 'ollama-cloud' | 'cursor';
|
export type ManagedProvider = 'exe-dev' | 'ollama-cloud' | 'cursor';
|
||||||
export type ManagedCredential = Record<string, string>;
|
export type ManagedCredential = Record<string, string>;
|
||||||
const providers = new Set<ManagedProvider>(['ollama-cloud', 'cursor']);
|
const providers = new Set<ManagedProvider>(['exe-dev', 'ollama-cloud', 'cursor']);
|
||||||
const directory = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota');
|
const directory = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota');
|
||||||
const target = (provider: ManagedProvider) => {
|
const target = (provider: ManagedProvider) => {
|
||||||
if (!providers.has(provider)) throw new Error('Unsupported credential provider');
|
if (!providers.has(provider)) throw new Error('Unsupported credential provider');
|
||||||
@@ -15,6 +16,7 @@ const clean = (value: unknown) => typeof value === 'string' && !/[\r\n]/.test(va
|
|||||||
|
|
||||||
export const normalizeCredential = (provider: ManagedProvider, value: unknown): ManagedCredential | null => {
|
export const normalizeCredential = (provider: ManagedProvider, value: unknown): ManagedCredential | null => {
|
||||||
const data = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
const data = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||||
|
if (provider === 'exe-dev') return clean(data.usageToken) ? { usageToken: clean(data.usageToken) } : null;
|
||||||
if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null;
|
if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null;
|
||||||
const accessToken = clean(data.accessToken);
|
const accessToken = clean(data.accessToken);
|
||||||
const refreshToken = clean(data.refreshToken);
|
const refreshToken = clean(data.refreshToken);
|
||||||
@@ -52,6 +54,7 @@ export const importCursorCredential = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => {
|
export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => {
|
||||||
|
if (provider === 'exe-dev') await fetchExeDevUsage(credential.usageToken);
|
||||||
if (provider === 'ollama-cloud') {
|
if (provider === 'ollama-cloud') {
|
||||||
const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
|
const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
|
||||||
if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed');
|
if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed');
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ describe('OpenCode Go quota provider (VS Code parity)', () => {
|
|||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.equal((request?.headers as Record<string, string>).Authorization, 'Bearer test-token');
|
assert.equal((request?.headers as Record<string, string>).Authorization, 'Bearer test-token');
|
||||||
|
assert.equal((request?.headers as Record<string, string>)['x-opencode-session'], 'openchamber-usage');
|
||||||
assert.equal(result.usage!.windows['5h']!.usedPercent, 25);
|
assert.equal(result.usage!.windows['5h']!.usedPercent, 25);
|
||||||
assert.throws(() => fs.statSync(legacyPath));
|
assert.throws(() => fs.statSync(legacyPath));
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user