mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 13:52:39 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfec7ac3ad | ||
|
|
5269c7b592 | ||
|
|
05dc2022c8 | ||
|
|
c0daf8e066 | ||
|
|
6ae6231d1f | ||
|
|
c77a6761af | ||
|
|
fd9cc5d693 | ||
|
|
7761d122d8 | ||
|
|
01a1d3b2b6 | ||
|
|
3bca9b7a92 | ||
|
|
8ffe7c75d5 | ||
|
|
74cf1931b1 |
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.1 - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- Unassign keyboard shortcuts and see the effective bindings in shortcut help ([#2510](https://github.com/getpaseo/paseo/pull/2510) by [@cleiter](https://github.com/cleiter), [#2985](https://github.com/getpaseo/paseo/pull/2985) by [@cleiter](https://github.com/cleiter))
|
||||
- Choose a Pure black theme ([#3012](https://github.com/getpaseo/paseo/pull/3012) by [@shrimpwtf](https://github.com/shrimpwtf))
|
||||
|
||||
### Improved
|
||||
|
||||
- Running agents use a clearer animated status ring ([c0daf8e](https://github.com/getpaseo/paseo/commit/c0daf8e066e0d7379723a2a6ddea20afc71ead20) by [@cleiter](https://github.com/cleiter))
|
||||
- Workspace Git updates stay responsive under file-watcher failures, heavy Git activity, and large shared-worktree repositories ([#3056](https://github.com/getpaseo/paseo/pull/3056), [#3033](https://github.com/getpaseo/paseo/pull/3033) by [@dwyanewang](https://github.com/dwyanewang), [#3078](https://github.com/getpaseo/paseo/pull/3078))
|
||||
- Kimi usage refreshes correctly and shows five-hour limits and reasoning levels ([#2743](https://github.com/getpaseo/paseo/pull/2743) by [@UnbrokenHunter](https://github.com/UnbrokenHunter))
|
||||
- Resize the desktop sidebar with touch ([#2953](https://github.com/getpaseo/paseo/pull/2953) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Direct LAN connections work again on macOS ([#3071](https://github.com/getpaseo/paseo/pull/3071))
|
||||
- Desktop update checks no longer report an error while platform downloads are still publishing ([#3076](https://github.com/getpaseo/paseo/pull/3076))
|
||||
- Copied lists keep their markers across selection boundaries ([#3055](https://github.com/getpaseo/paseo/pull/3055))
|
||||
- Switching hosts keeps the active project selected ([#3051](https://github.com/getpaseo/paseo/pull/3051))
|
||||
- New Workspace keeps project and isolation controls inside the composer ([#3050](https://github.com/getpaseo/paseo/pull/3050))
|
||||
- Session rename is reachable from the mobile sessions menu ([#2924](https://github.com/getpaseo/paseo/pull/2924) by [@kaspesi](https://github.com/kaspesi))
|
||||
- Desktop notification sounds play again ([#2582](https://github.com/getpaseo/paseo/pull/2582) by [@gstamp](https://github.com/gstamp))
|
||||
- Codex slash commands omit disabled skills ([#2759](https://github.com/getpaseo/paseo/pull/2759) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Exact ignored file paths resolve correctly ([#3042](https://github.com/getpaseo/paseo/pull/3042) by [@cleiter](https://github.com/cleiter))
|
||||
- Claude question notifications summarize the requested input ([#2925](https://github.com/getpaseo/paseo/pull/2925) by [@SihyeonJeon](https://github.com/SihyeonJeon))
|
||||
- The crash screen stays readable and keeps its retry action reachable ([#3043](https://github.com/getpaseo/paseo/pull/3043))
|
||||
|
||||
## 0.3.0 - 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -223,7 +223,7 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
|
||||
|
||||
There is exactly one token per status signal — `statusSuccess`, `statusDanger`, `statusWarning`, `statusMerged` — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in `packages/app/src/styles/theme.ts` and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list.
|
||||
|
||||
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: pushed away from the surface rather than toward it (darker in light, lighter in dark) and at 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's lightness the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. All four move together; regenerate the set, never one hue.
|
||||
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
|
||||
|
||||
Status pills are the status token as foreground on a 10%-alpha tint of the same token (`${token}1a`), with a 20% border (`${token}33`). The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
|
||||
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35299,7 +35299,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36353,12 +36353,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.3.0",
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/server": "0.3.0",
|
||||
"@getpaseo/client": "0.3.1",
|
||||
"@getpaseo/protocol": "0.3.1",
|
||||
"@getpaseo/server": "0.3.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36605,10 +36605,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/relay": "0.3.0",
|
||||
"@getpaseo/protocol": "0.3.1",
|
||||
"@getpaseo/relay": "0.3.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36619,7 +36619,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36862,7 +36862,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37758,7 +37758,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37990,7 +37990,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38003,7 +38003,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38221,15 +38221,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.3.0",
|
||||
"@getpaseo/highlight": "0.3.0",
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/relay": "0.3.0",
|
||||
"@getpaseo/client": "0.3.1",
|
||||
"@getpaseo/highlight": "0.3.1",
|
||||
"@getpaseo/protocol": "0.3.1",
|
||||
"@getpaseo/relay": "0.3.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38778,7 +38778,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -118,6 +118,53 @@ async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
|
||||
async function selectAssistantListItemFromMarker(
|
||||
page: Page,
|
||||
listTag: "ol" | "ul",
|
||||
itemText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const item = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
|
||||
.filter({ hasText: itemText })
|
||||
.locator(':scope > [data-paseo-markdown-tag="li"]')
|
||||
.filter({ hasText: itemText });
|
||||
|
||||
await item.evaluate((element, selectedEndText) => {
|
||||
const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]');
|
||||
const markerText = marker?.firstChild;
|
||||
if (!(markerText instanceof Text)) {
|
||||
throw new Error("Expected rendered list marker text");
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let endNode: Text | null = null;
|
||||
let endOffset = -1;
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
const offset = node.textContent?.indexOf(selectedEndText) ?? -1;
|
||||
if (node instanceof Text && offset >= 0) {
|
||||
endNode = node;
|
||||
endOffset = offset + selectedEndText.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!endNode) {
|
||||
throw new Error(`Could not find list item selection end: ${selectedEndText}`);
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(markerText, 0);
|
||||
range.setEnd(endNode, endOffset);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}, endText);
|
||||
}
|
||||
|
||||
async function doubleClickAssistantMarkdownText(
|
||||
page: Page,
|
||||
tag: "code" | "strong",
|
||||
@@ -405,6 +452,24 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
|
||||
await selectAssistantListItemFromMarker(page, "ul", "First issue", "failure.");
|
||||
await copySelection(page);
|
||||
|
||||
const draggedBulletClipboard = await readRichClipboard(page);
|
||||
expect(draggedBulletClipboard.plainText).toBe(
|
||||
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
|
||||
);
|
||||
expect(draggedBulletClipboard.html).toContain(
|
||||
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
|
||||
await selectAssistantListItemFromMarker(page, "ol", "Sixth item", "Sixth item");
|
||||
await copySelection(page);
|
||||
|
||||
const draggedNumberClipboard = await readRichClipboard(page);
|
||||
expect(draggedNumberClipboard.plainText).toBe("6. Sixth item");
|
||||
expect(draggedNumberClipboard.html).toContain("<div>6. Sixth item</div>");
|
||||
|
||||
await selectAssistantText(page, "docs");
|
||||
await copySelection(page);
|
||||
|
||||
|
||||
@@ -114,16 +114,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expectDirectHostUriHidden(page);
|
||||
});
|
||||
|
||||
test("browser web hides direct connection custom headers", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
await toggleHostAdvanced(page);
|
||||
|
||||
await expect(page.getByTestId("direct-header-add")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
@@ -17,6 +17,49 @@ interface PairingHostInput {
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export async function prepareLocalPairingHost(
|
||||
page: Page,
|
||||
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
||||
additionalHosts: PairingHostInput[] = [],
|
||||
): Promise<void> {
|
||||
await page.addInitScript((localServerId) => {
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_daemon_status") {
|
||||
return {
|
||||
serverId: localServerId,
|
||||
status: "running",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "",
|
||||
version: null,
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
opener: {
|
||||
openUrl: async (url: string) => {
|
||||
localStorage.setItem("@paseo:e2e-opened-url", url);
|
||||
},
|
||||
},
|
||||
};
|
||||
}, daemon.serverId);
|
||||
|
||||
await preparePairingHost(page, daemon, additionalHosts);
|
||||
}
|
||||
|
||||
export async function preparePairingHost(
|
||||
page: Page,
|
||||
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,8 +5,8 @@ const fixture = `
|
||||
<div data-testid="assistant-message">
|
||||
<div data-paseo-markdown-tag="p">Prefix <span data-paseo-markdown-tag="strong">bold text</span> and <span data-paseo-markdown-tag="code">inline code</span> suffix.</div>
|
||||
<div data-paseo-markdown-tag="ul">
|
||||
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true">•</span><div><span>First bullet text</span></div></div>
|
||||
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true">•</span><div><span>Second bullet text</span></div></div>
|
||||
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span><div><span>First bullet text</span></div></div>
|
||||
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span><div><span>Second bullet text</span></div></div>
|
||||
</div>
|
||||
<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="ts"><span data-paseo-markdown-tag="code">const answer = true;</span></div>
|
||||
</div>
|
||||
@@ -203,6 +203,97 @@ describe("assistant selection copy ranges", () => {
|
||||
expect(copiedMarkdown(selectNodeContents(item))).toBe("- First bullet text");
|
||||
});
|
||||
|
||||
it("retains a bullet when a drag selects from the rendered marker through the item text", () => {
|
||||
const message = mountFixture();
|
||||
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
|
||||
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
|
||||
|
||||
const content = createAssistantSelectionClipboardContent(
|
||||
selectRange(marker, 0, itemText, textNode(itemText).length),
|
||||
);
|
||||
expect(content?.plainText).toBe("- First bullet text");
|
||||
expect(content?.html).toContain("<div>- First bullet text</div>");
|
||||
expect(content?.html).not.toContain("<ul>");
|
||||
expect(content?.html).not.toContain("<li>");
|
||||
});
|
||||
|
||||
it("retains a bullet when a drag includes the marker and part of the item text", () => {
|
||||
const message = mountFixture();
|
||||
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
|
||||
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
|
||||
|
||||
expect(copiedMarkdown(selectRange(marker, 0, itemText, 5))).toBe("- First");
|
||||
});
|
||||
|
||||
it("does not invent a bullet when a drag starts after the rendered marker", () => {
|
||||
const message = mountFixture();
|
||||
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
|
||||
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
|
||||
|
||||
expect(
|
||||
copiedMarkdown(
|
||||
selectRange(marker, textNode(marker).length, itemText, textNode(itemText).length),
|
||||
),
|
||||
).toBe("First bullet text");
|
||||
});
|
||||
|
||||
it("retains every selected marker across a partial multi-item drag", () => {
|
||||
const message = mountFixture();
|
||||
const markerSelector = '[data-paseo-markdown-list-marker="true"]';
|
||||
const textSelector = '[data-paseo-markdown-tag="li"] div span';
|
||||
const firstMarker = fixtureElement(message, markerSelector);
|
||||
const secondText = fixtureElement(message, textSelector, 1);
|
||||
|
||||
expect(copiedMarkdown(selectRange(firstMarker, 0, secondText, 6))).toBe(
|
||||
"- First bullet text\n- Second",
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the original number when a marker drag starts mid-list", () => {
|
||||
const message = mountFixture();
|
||||
const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]');
|
||||
list.setAttribute("data-paseo-markdown-tag", "ol");
|
||||
list.setAttribute("data-paseo-markdown-list-start", "5");
|
||||
const markers = list.querySelectorAll<HTMLElement>('[data-paseo-markdown-list-marker="true"]');
|
||||
markers.item(0).textContent = "5.";
|
||||
markers.item(1).textContent = "6.";
|
||||
const secondMarker = fixtureElement(list, '[data-paseo-markdown-list-marker="true"]', 1);
|
||||
const secondText = fixtureElement(list, '[data-paseo-markdown-tag="li"] div span', 1);
|
||||
|
||||
expect(
|
||||
copiedMarkdown(selectRange(secondMarker, 0, secondText, textNode(secondText).length)),
|
||||
).toBe("6. Second bullet text");
|
||||
});
|
||||
|
||||
it("retains nested markers when a drag includes the complete outer item", () => {
|
||||
const message = mountFixture();
|
||||
const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
|
||||
firstItem.innerHTML = [
|
||||
'<span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span>',
|
||||
"<div>",
|
||||
"<span>Outer text</span>",
|
||||
'<div data-paseo-markdown-tag="ul">',
|
||||
'<div data-paseo-markdown-tag="li">',
|
||||
'<span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span>',
|
||||
"<div><span>Inner text</span></div>",
|
||||
"</div>",
|
||||
"</div>",
|
||||
"</div>",
|
||||
].join("");
|
||||
const outerMarker = fixtureElement(
|
||||
firstItem,
|
||||
':scope > [data-paseo-markdown-list-marker="true"]',
|
||||
);
|
||||
const innerText = fixtureElement(
|
||||
firstItem,
|
||||
':scope [data-paseo-markdown-tag="ul"] > [data-paseo-markdown-tag="li"] > div > span',
|
||||
);
|
||||
|
||||
expect(copiedMarkdown(selectRange(outerMarker, 0, innerText, textNode(innerText).length))).toBe(
|
||||
"- Outer text\n - Inner text",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves paragraph breaks when rich HTML flattens a loose list item", () => {
|
||||
const message = mountFixture();
|
||||
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
|
||||
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
|
||||
MARKDOWN_COPY_TAG_ATTRIBUTE,
|
||||
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
|
||||
@@ -278,7 +279,7 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
if (tag === "p" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
const isSelectableSemantic = tag !== null && tag !== "ol" && tag !== "ul";
|
||||
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
||||
if (
|
||||
(isSelectableSemantic || element.tagName === "A") &&
|
||||
selectionStaysInsideElement(range, element)
|
||||
@@ -287,7 +288,12 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
}
|
||||
if (tag === "ol" || tag === "ul") {
|
||||
const selectedItems = selectedListItems(element, range);
|
||||
return selectedItems.some((item) => hasSelectedAllContents(range, item, true));
|
||||
return selectedItems.some(
|
||||
(item) => hasSelectedListMarker(range, item) || hasSelectedAllContents(range, item, true),
|
||||
);
|
||||
}
|
||||
if (tag === "li" && hasSelectedListMarker(range, element)) {
|
||||
return true;
|
||||
}
|
||||
return hasSelectedAllContents(range, element, tag === "li");
|
||||
}
|
||||
@@ -311,6 +317,31 @@ function selectedListItems(list: Element, range: Range): Element[] {
|
||||
);
|
||||
}
|
||||
|
||||
function hasSelectedListMarker(range: Range, item: Element): boolean {
|
||||
const marker = item.querySelector(`:scope > [${MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE}]`);
|
||||
if (!marker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const markerContents = document.createRange();
|
||||
markerContents.selectNodeContents(marker);
|
||||
const selectionEndsBeforeMarker =
|
||||
range.compareBoundaryPoints(Range.END_TO_START, markerContents) >= 0;
|
||||
const selectionStartsAfterMarker =
|
||||
range.compareBoundaryPoints(Range.START_TO_END, markerContents) <= 0;
|
||||
if (selectionEndsBeforeMarker || selectionStartsAfterMarker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, markerContents) > 0) {
|
||||
markerContents.setStart(range.startContainer, range.startOffset);
|
||||
}
|
||||
if (range.compareBoundaryPoints(Range.END_TO_END, markerContents) < 0) {
|
||||
markerContents.setEnd(range.endContainer, range.endOffset);
|
||||
}
|
||||
return hasMarkdownContent(markerContents.cloneContents(), true);
|
||||
}
|
||||
|
||||
function normalizeOrderedListStart(copy: Element, original: Element, range: Range): void {
|
||||
if (original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) !== "ol") {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const MARKDOWN_COPY_TAG_ATTRIBUTE = "data-paseo-markdown-tag";
|
||||
export const MARKDOWN_COPY_IGNORE_ATTRIBUTE = "data-paseo-markdown-ignore";
|
||||
export const MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE = "data-paseo-markdown-list-marker";
|
||||
export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap";
|
||||
export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start";
|
||||
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
|
||||
@@ -28,6 +29,7 @@ export const markdownCopyDataSet = {
|
||||
hr: { paseoMarkdownTag: "hr" },
|
||||
ignore: { paseoMarkdownIgnore: "true" },
|
||||
li: { paseoMarkdownTag: "li" },
|
||||
listMarker: { paseoMarkdownIgnore: "true", paseoMarkdownListMarker: "true" },
|
||||
ol: { paseoMarkdownTag: "ol" },
|
||||
p: { paseoMarkdownTag: "p" },
|
||||
pre: { paseoMarkdownTag: "pre" },
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getIsElectron, isNative } from "@/constants/platform";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2, Plus, X } from "lucide-react-native";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import {
|
||||
@@ -13,11 +12,6 @@ import {
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import {
|
||||
prepareConnectionHeaders,
|
||||
type ConnectionHeaderDraft,
|
||||
type ConnectionHeaderValidationIssue,
|
||||
} from "@/utils/connection-headers";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -129,30 +123,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
advancedContent: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
headerSection: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
headerList: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerInput: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
@@ -211,94 +181,6 @@ function prepareDirectConnection(
|
||||
};
|
||||
}
|
||||
|
||||
function headerIssueMessage(
|
||||
issue: ConnectionHeaderValidationIssue,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
if (issue.type === "missingName") return t("pairing.direct.headers.errors.missingName");
|
||||
if (issue.type === "invalidName") {
|
||||
return t("pairing.direct.headers.errors.invalidName", { name: issue.name });
|
||||
}
|
||||
if (issue.type === "invalidValue") {
|
||||
return t("pairing.direct.headers.errors.invalidValue", { name: issue.name });
|
||||
}
|
||||
return t("pairing.direct.headers.errors.duplicateName", { name: issue.name });
|
||||
}
|
||||
|
||||
interface ConnectionHeaderRowProps {
|
||||
draft: ConnectionHeaderDraft;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
placeholderTextColor: string;
|
||||
iconColor: string;
|
||||
onUpdate: (id: number, field: "name" | "value", value: string) => void;
|
||||
onRemove: (id: number) => void;
|
||||
}
|
||||
|
||||
function ConnectionHeaderRow({
|
||||
draft,
|
||||
index,
|
||||
disabled,
|
||||
placeholderTextColor,
|
||||
iconColor,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: ConnectionHeaderRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleNameChange = useCallback(
|
||||
(value: string) => onUpdate(draft.id, "name", value),
|
||||
[draft.id, onUpdate],
|
||||
);
|
||||
const handleValueChange = useCallback(
|
||||
(value: string) => onUpdate(draft.id, "value", value),
|
||||
[draft.id, onUpdate],
|
||||
);
|
||||
const handleRemove = useCallback(() => onRemove(draft.id), [draft.id, onRemove]);
|
||||
|
||||
return (
|
||||
<View style={styles.headerRow}>
|
||||
<AdaptiveTextInput
|
||||
testID={`direct-header-name-${index}`}
|
||||
nativeID={`direct-header-name-${draft.id}`}
|
||||
accessibilityLabel={t("pairing.direct.headers.name")}
|
||||
initialValue={draft.name}
|
||||
value={draft.name}
|
||||
onChangeText={handleNameChange}
|
||||
placeholder={t("pairing.direct.headers.name")}
|
||||
placeholderTextColor={placeholderTextColor}
|
||||
style={[styles.input, styles.headerInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!disabled}
|
||||
/>
|
||||
<AdaptiveTextInput
|
||||
testID={`direct-header-value-${index}`}
|
||||
nativeID={`direct-header-value-${draft.id}`}
|
||||
accessibilityLabel={t("pairing.direct.headers.value")}
|
||||
initialValue={draft.value}
|
||||
value={draft.value}
|
||||
onChangeText={handleValueChange}
|
||||
placeholder={t("pairing.direct.headers.value")}
|
||||
placeholderTextColor={placeholderTextColor}
|
||||
style={[styles.input, styles.headerInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!disabled}
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={handleRemove}
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("pairing.direct.headers.remove")}
|
||||
testID={`direct-header-remove-${index}`}
|
||||
>
|
||||
<X size={18} color={iconColor} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function draftFromConnectionUri(uri: string): DirectConnectionDraft {
|
||||
const parsed = parseConnectionUri(uri);
|
||||
return {
|
||||
@@ -423,8 +305,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const [advancedUri, setAdvancedUri] = useState("");
|
||||
const [headerDrafts, setHeaderDrafts] = useState<ConnectionHeaderDraft[]>([]);
|
||||
const nextHeaderId = useRef(1);
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
@@ -435,8 +315,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsPasswordVisible(false);
|
||||
setIsAdvancedOpen(false);
|
||||
setAdvancedUri("");
|
||||
setHeaderDrafts([]);
|
||||
nextHeaderId.current = 1;
|
||||
bumpInputResetKey();
|
||||
}, []);
|
||||
|
||||
@@ -491,18 +369,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
if (isSaving) return;
|
||||
|
||||
let connection: PreparedDirectConnection;
|
||||
let headers: Record<string, string> | undefined;
|
||||
try {
|
||||
connection = prepareDirectConnection(
|
||||
{ host, port, useTls, password },
|
||||
directConnectionLabels,
|
||||
);
|
||||
const preparedHeaders = prepareConnectionHeaders(headerDrafts);
|
||||
if (preparedHeaders.issue) {
|
||||
setErrorMessage(headerIssueMessage(preparedHeaders.issue, t));
|
||||
return;
|
||||
}
|
||||
headers = preparedHeaders.headers;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
|
||||
@@ -518,7 +389,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
endpoint: connection.endpoint,
|
||||
useTls: connection.useTls,
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
...(headers ? { headers } : {}),
|
||||
});
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
|
||||
@@ -555,7 +425,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
daemons,
|
||||
directConnectionLabels,
|
||||
handleClose,
|
||||
headerDrafts,
|
||||
host,
|
||||
isMobile,
|
||||
isSaving,
|
||||
@@ -584,22 +453,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsPasswordVisible((current) => !current);
|
||||
}, []);
|
||||
|
||||
const handleAddHeader = useCallback(() => {
|
||||
const id = nextHeaderId.current;
|
||||
nextHeaderId.current += 1;
|
||||
setHeaderDrafts((current) => [...current, { id, name: "", value: "" }]);
|
||||
}, []);
|
||||
|
||||
const handleUpdateHeader = useCallback((id: number, field: "name" | "value", value: string) => {
|
||||
setHeaderDrafts((current) =>
|
||||
current.map((draft) => (draft.id === id ? { ...draft, [field]: value } : draft)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleRemoveHeader = useCallback((id: number) => {
|
||||
setHeaderDrafts((current) => current.filter((draft) => draft.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleToggleAdvanced = useCallback(() => {
|
||||
if (!isAdvancedOpen) {
|
||||
try {
|
||||
@@ -756,58 +609,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
||||
</Pressable>
|
||||
{isAdvancedOpen ? (
|
||||
<View style={styles.advancedContent}>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
onChangeText={setAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleToggleAdvanced}
|
||||
/>
|
||||
|
||||
{isNative || getIsElectron() ? (
|
||||
<View style={styles.headerSection}>
|
||||
<View style={styles.headerTitleRow}>
|
||||
<Text style={styles.label}>{t("pairing.direct.headers.title")}</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={Plus}
|
||||
onPress={handleAddHeader}
|
||||
disabled={isSaving}
|
||||
testID="direct-header-add"
|
||||
>
|
||||
{t("pairing.direct.headers.add")}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={styles.headerList}>
|
||||
{headerDrafts.map((draft, index) => (
|
||||
<ConnectionHeaderRow
|
||||
key={draft.id}
|
||||
draft={draft}
|
||||
index={index}
|
||||
disabled={isSaving}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
onUpdate={handleUpdateHeader}
|
||||
onRemove={handleRemoveHeader}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
onChangeText={setAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleToggleAdvanced}
|
||||
/>
|
||||
) : null}
|
||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@getpaseo/protocol/agent-lifecycle";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
|
||||
|
||||
export function AgentStatusDot({
|
||||
status,
|
||||
@@ -56,8 +57,8 @@ function isAgentLifecycleStatus(value: string): value is AgentLifecycleStatus {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -6,37 +6,16 @@ import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { formatShortcut } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import {
|
||||
buildEffectiveBindings,
|
||||
buildKeyboardShortcutHelpSections,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { filterShortcutHelpSections } from "@/keyboard/shortcut-help-search";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
|
||||
const SNAP_POINTS: string[] = ["70%", "92%"];
|
||||
|
||||
function shortcutSearchAliases(keys: string[], shortcutOs: "mac" | "non-mac"): string {
|
||||
const aliases = keys.map((key) => {
|
||||
if (shortcutOs === "mac") {
|
||||
if (key === "mod" || key === "meta") return ["cmd", "command"];
|
||||
if (key === "alt") return ["alt", "option"];
|
||||
} else {
|
||||
if (key === "mod" || key === "ctrl") return ["ctrl", "control"];
|
||||
if (key === "meta") return ["win", "windows"];
|
||||
}
|
||||
return [key];
|
||||
});
|
||||
const combinations = aliases.reduce<string[][]>(
|
||||
(prefixes, choices) =>
|
||||
prefixes.flatMap((prefix) => choices.map((choice) => [...prefix, choice])),
|
||||
[[]],
|
||||
);
|
||||
return combinations
|
||||
.flatMap((combination) => [combination.join(" "), combination.join("+")])
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
||||
@@ -54,33 +33,10 @@ export function KeyboardShortcutsDialog() {
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }, bindings),
|
||||
[bindings, isDesktopApp, isMac],
|
||||
);
|
||||
const visibleSections = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
if (!normalizedQuery) return sections;
|
||||
|
||||
return sections.flatMap((section) => {
|
||||
const sectionTitle = t(section.titleKey);
|
||||
if (sectionTitle.toLocaleLowerCase().includes(normalizedQuery)) {
|
||||
return [section];
|
||||
}
|
||||
|
||||
const rows = section.rows.filter((row) => {
|
||||
const searchText = [
|
||||
t(row.labelKey),
|
||||
row.noteKey ? t(row.noteKey) : row.note,
|
||||
row.keys.join(" "),
|
||||
formatShortcut(row.keys, shortcutOs),
|
||||
shortcutSearchAliases(row.keys, shortcutOs),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLocaleLowerCase();
|
||||
return searchText.includes(normalizedQuery);
|
||||
});
|
||||
|
||||
return rows.length > 0 ? [{ ...section, rows }] : [];
|
||||
});
|
||||
}, [query, sections, shortcutOs, t]);
|
||||
const visibleSections = useMemo(
|
||||
() => filterShortcutHelpSections({ sections, query, translate: t, shortcutOs }),
|
||||
[query, sections, shortcutOs, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery("");
|
||||
@@ -114,14 +70,18 @@ export function KeyboardShortcutsDialog() {
|
||||
<Text style={styles.sectionTitle}>{t(section.titleKey)}</Text>
|
||||
<View style={styles.rows}>
|
||||
{section.rows.map((row) => (
|
||||
<View key={row.id} style={styles.row}>
|
||||
<View key={row.id} style={styles.row} testID={`shortcut-help-row-${row.id}`}>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowLabel}>{t(row.labelKey)}</Text>
|
||||
{row.note ? (
|
||||
<Text style={styles.rowNote}>{row.noteKey ? t(row.noteKey) : row.note}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Shortcut keys={row.keys} style={styles.rowShortcut} />
|
||||
{row.chord === null ? (
|
||||
<Text style={styles.rowUnassigned}>{t("settings.shortcuts.unassigned")}</Text>
|
||||
) : (
|
||||
<Shortcut chord={row.chord} style={styles.rowShortcut} />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -179,6 +139,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
rowShortcut: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
// Matches the settings row's unassigned label, so the two screens describe the
|
||||
// same state the same way.
|
||||
rowUnassigned: {
|
||||
alignSelf: "flex-start",
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
empty: {
|
||||
paddingVertical: theme.spacing[6],
|
||||
textAlign: "center",
|
||||
|
||||
@@ -1806,7 +1806,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
|
||||
return (
|
||||
<View key={node.key} style={styles.list_item} dataSet={markdownCopyDataSet.li}>
|
||||
<Text style={iconStyle} dataSet={markdownCopyDataSet.ignore}>
|
||||
<Text style={iconStyle} dataSet={markdownCopyDataSet.listMarker}>
|
||||
{marker}
|
||||
</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>
|
||||
|
||||
@@ -376,18 +376,21 @@ function projectKebabStyle({
|
||||
|
||||
function getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
return [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -871,6 +874,7 @@ function ProjectHeaderRow({
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||
const localDaemonServerId = useLocalDaemonServerId();
|
||||
@@ -917,6 +921,17 @@ function ProjectHeaderRow({
|
||||
setContextMenuOpen(open);
|
||||
if (open) setIsHovered(false);
|
||||
}, []);
|
||||
const handleProjectPressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
interaction.handlePressIn(event);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleProjectPressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
interaction.handlePressOut();
|
||||
}, [interaction]);
|
||||
|
||||
const projectRowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
@@ -937,7 +952,7 @@ function ProjectHeaderRow({
|
||||
iconDataUri={iconDataUri}
|
||||
statusBucket={statusBucket}
|
||||
projectViewKey={project.viewKey}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
|
||||
chevron={chevron}
|
||||
showChevron={isHovered && chevron !== null}
|
||||
isArchiving={isArchiving}
|
||||
@@ -982,10 +997,10 @@ function ProjectHeaderRow({
|
||||
<PressHighlight
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowHovered}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
highlightStyle={styles.projectRowPressed}
|
||||
onPressIn={handleProjectPressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPressOut={handleProjectPressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
@@ -1008,10 +1023,10 @@ function ProjectHeaderRow({
|
||||
enabledOnMobile={false}
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowHovered}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
highlightStyle={styles.projectRowPressed}
|
||||
onPressIn={handleProjectPressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPressOut={handleProjectPressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
@@ -1064,6 +1079,7 @@ function WorkspaceRowInner({
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const _isCompact = useIsCompactFormFactor();
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
@@ -1083,6 +1099,17 @@ function WorkspaceRowInner({
|
||||
}
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
const handleWorkspacePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
interaction.handlePressIn(event);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleWorkspacePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
interaction.handlePressOut();
|
||||
}, [interaction]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
|
||||
@@ -1093,6 +1120,7 @@ function WorkspaceRowInner({
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
const workspaceRowStyle = getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
});
|
||||
@@ -1127,10 +1155,10 @@ function WorkspaceRowInner({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowHovered}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handleWorkspacePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPressOut={handleWorkspacePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -1140,7 +1168,7 @@ function WorkspaceRowInner({
|
||||
leadingProjectName={leadingProjectName}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
|
||||
@@ -12,8 +12,13 @@ import {
|
||||
} from "@/utils/project-status-badge-content";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { STATUS_INDICATOR_ALERT_SIZE } from "@/utils/status-indicator-geometry";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import {
|
||||
STATUS_INDICATOR_ALERT_SIZE,
|
||||
STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
} from "@/utils/status-indicator-geometry";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { getStatusRingOffset } from "@/components/status-ring/geometry";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
|
||||
// Every surfaced status shares one badge shell, so the badge never changes size or position
|
||||
// between states. Only the thing inside it changes.
|
||||
@@ -29,7 +34,6 @@ const STATUS_BADGE_OFFSET = -4;
|
||||
//
|
||||
// The filled alert occupies the full badge shell so needs-input remains more prominent than
|
||||
// the passive status dots.
|
||||
const STATUS_BADGE_DOT_SIZE = 6;
|
||||
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
|
||||
// workspaceBranchText) so the icon centers on the title rather than floating above it.
|
||||
const LEADING_SLOT_HEIGHT = 20;
|
||||
@@ -65,7 +69,7 @@ export function ProjectLeadingVisual({
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
projectViewKey: string;
|
||||
/** The row's current background, so the status badge can knock out of it. */
|
||||
backdrop: SurfaceBackdrop;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
chevron?: "expand" | "collapse" | null;
|
||||
showChevron?: boolean;
|
||||
isArchiving?: boolean;
|
||||
@@ -116,7 +120,7 @@ export function ProjectStatusIndicator({
|
||||
projectViewKey: string;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
/** The row's current background, so the status badge can knock out of it. */
|
||||
backdrop: SurfaceBackdrop;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
loading?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
@@ -164,8 +168,23 @@ function ProjectStatusBadge({
|
||||
}: {
|
||||
content: ProjectStatusBadgeContent;
|
||||
statusBucket: SidebarStateBucket;
|
||||
backdrop: SurfaceBackdrop;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
}) {
|
||||
// Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout,
|
||||
// so nesting it inside would clip it against the very thing that was meant to separate it from
|
||||
// the icon. It anchors to the same corner instead, growing around the centre the dot had.
|
||||
if (content.kind === "dot" && content.bucket === "running") {
|
||||
return (
|
||||
<View
|
||||
role="status"
|
||||
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
|
||||
style={styles.statusRingAnchor}
|
||||
testID="project-status-badge"
|
||||
>
|
||||
<StatusRing backdrop={backdrop} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
role="status"
|
||||
@@ -182,14 +201,14 @@ function ProjectStatusBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusBadgeBackdropStyle(backdrop: SurfaceBackdrop): ViewStyle {
|
||||
function getStatusBadgeBackdropStyle(backdrop: SidebarSurfaceBackdrop): ViewStyle {
|
||||
switch (backdrop) {
|
||||
case "surfaceSidebar":
|
||||
return styles.statusBadgeOnSidebar;
|
||||
case "surfaceSidebarHover":
|
||||
return styles.statusBadgeOnSidebarHover;
|
||||
case "surface2":
|
||||
return styles.statusBadgeOnSurface2;
|
||||
default:
|
||||
return styles.statusBadgeOnSidebar;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,8 +259,8 @@ const styles = StyleSheet.create((theme) => {
|
||||
// so this badge can't drift from the status dots everywhere else.
|
||||
const statusDot = (bucket: ProjectStatusBadgeDotBucket) =>
|
||||
({
|
||||
width: STATUS_BADGE_DOT_SIZE,
|
||||
height: STATUS_BADGE_DOT_SIZE,
|
||||
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: getStatusDotColor({ theme, bucket }) ?? undefined,
|
||||
}) as const;
|
||||
@@ -282,6 +301,12 @@ const styles = StyleSheet.create((theme) => {
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
},
|
||||
// Same corner as the shell, re-centred for the wider ring.
|
||||
statusRingAnchor: {
|
||||
position: "absolute",
|
||||
right: getStatusRingOffset(STATUS_BADGE_OFFSET, STATUS_BADGE_SIZE),
|
||||
bottom: getStatusRingOffset(STATUS_BADGE_OFFSET, STATUS_BADGE_SIZE),
|
||||
},
|
||||
statusBadgeOnSidebar: { backgroundColor: theme.colors.surfaceSidebar },
|
||||
statusBadgeOnSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
|
||||
statusBadgeOnSurface2: { backgroundColor: theme.colors.surface2 },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
|
||||
/**
|
||||
* Which surface a sidebar row is currently painting, so anything knocking out of it — the status
|
||||
@@ -9,19 +9,19 @@ import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
* here rather than three ternaries beside three stylesheets: drifting from them is how the badge
|
||||
* ends up with a halo, and a halo in one grouping mode but not the other is worse than either.
|
||||
*
|
||||
* The pressed background is deliberately absent. It lives in a Pressable style callback and lasts
|
||||
* as long as a finger is down, which is not long enough to see a mismatched ring.
|
||||
*/
|
||||
export function getSidebarRowBackdrop({
|
||||
isDragging = false,
|
||||
isPressed = false,
|
||||
selected = false,
|
||||
isHovered = false,
|
||||
}: {
|
||||
isDragging?: boolean;
|
||||
isPressed?: boolean;
|
||||
selected?: boolean;
|
||||
isHovered?: boolean;
|
||||
}): SurfaceBackdrop {
|
||||
if (isDragging) return "surface2";
|
||||
}): SidebarSurfaceBackdrop {
|
||||
if (isDragging || isPressed) return "surface2";
|
||||
if (selected || isHovered) return "surfaceSidebarHover";
|
||||
return "surfaceSidebar";
|
||||
}
|
||||
|
||||
@@ -687,12 +687,15 @@ function StatusWorkspaceRowInner({
|
||||
inStatusGroup?: boolean;
|
||||
}) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const handlePressIn = useCallback(() => setIsPressed(true), []);
|
||||
const handlePressOut = useCallback(() => setIsPressed(false), []);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace}>
|
||||
@@ -713,6 +716,7 @@ function StatusWorkspaceRowInner({
|
||||
showShortcut,
|
||||
});
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
@@ -743,7 +747,9 @@ function StatusWorkspaceRowInner({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowHovered}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={onPress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -753,7 +759,7 @@ function StatusWorkspaceRowInner({
|
||||
leadingProjectName={projectName}
|
||||
leadingProjectIconDataUri={projectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={getSidebarRowBackdrop({ selected, isHovered })}
|
||||
backdrop={getSidebarRowBackdrop({ isPressed, selected, isHovered })}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
@@ -857,10 +863,12 @@ function StatusWorkspaceActionSlot({
|
||||
}
|
||||
|
||||
function getStatusWorkspaceRowStyle({
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
}: {
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
inStatusGroup: boolean;
|
||||
@@ -870,6 +878,7 @@ function getStatusWorkspaceRowStyle({
|
||||
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
|
||||
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import {
|
||||
WorkspaceMetaRow,
|
||||
type WorkspaceServiceSummary,
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import {
|
||||
STATUS_INDICATOR_ALERT_SIZE,
|
||||
STATUS_INDICATOR_DOT_SIZE,
|
||||
STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
} from "@/utils/status-indicator-geometry";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
|
||||
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
|
||||
@@ -32,11 +34,6 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba
|
||||
const SCRIM_WIDTH = 48;
|
||||
const SCRIM_SOLID_OFFSET = "55%";
|
||||
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.surface0,
|
||||
@@ -143,7 +140,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
leadingProjectIconDataUri?: string | null;
|
||||
serviceSummary?: WorkspaceServiceSummary | null;
|
||||
/** The row's current background, so the project status badge can knock out of it. */
|
||||
backdrop: SurfaceBackdrop;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
isHovered: boolean;
|
||||
isLoading: boolean;
|
||||
isCreating?: boolean;
|
||||
@@ -221,14 +218,14 @@ function WorkspaceStatusIndicator({
|
||||
loading?: boolean;
|
||||
reserveIdleSpace?: boolean;
|
||||
}) {
|
||||
// Busy is a dot here for the same reason it is on a project icon: every status in the
|
||||
// sidebar is a dot, and a row with a project icon simply moves that dot onto the icon.
|
||||
// A row starting up and a row working are both busy, so they share the dot and differ only
|
||||
// in testID.
|
||||
// Busy is the only status that moves, and it is the ring rather than a dot for the same
|
||||
// reason it is a dot elsewhere: every status in the sidebar sits in this one slot, so busy
|
||||
// has to fill it without displacing anything. A row starting up and a row working are both
|
||||
// busy, so they share the ring and differ only in testID.
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
|
||||
<View style={styles.standaloneRunningDot} />
|
||||
<StatusRing />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -236,7 +233,7 @@ function WorkspaceStatusIndicator({
|
||||
if (shouldRenderSyncedStatusLoader({ bucket })) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-running">
|
||||
<View style={styles.standaloneRunningDot} />
|
||||
<StatusRing />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -275,50 +272,16 @@ function WorkspaceStatusIndicator({
|
||||
else KindIcon = ThemedFolder;
|
||||
|
||||
const dotColorStyle = getStatusDotColorStyle(bucket);
|
||||
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
|
||||
? EMPHASIZED_STATUS_DOT_SIZE
|
||||
: DEFAULT_STATUS_DOT_SIZE;
|
||||
const statusDotOffset =
|
||||
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
|
||||
? EMPHASIZED_STATUS_DOT_OFFSET
|
||||
: DEFAULT_STATUS_DOT_OFFSET;
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
|
||||
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
|
||||
{dotColorStyle ? (
|
||||
<StatusDotOverlay
|
||||
dotColorStyle={dotColorStyle}
|
||||
size={statusDotSize}
|
||||
offset={statusDotOffset}
|
||||
/>
|
||||
) : null}
|
||||
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDotOverlay({
|
||||
dotColorStyle,
|
||||
size,
|
||||
offset,
|
||||
}: {
|
||||
dotColorStyle: ViewStyle;
|
||||
size: number;
|
||||
offset: number;
|
||||
}) {
|
||||
const overlayStyle = useMemo(
|
||||
() => [
|
||||
styles.statusDotOverlay,
|
||||
dotColorStyle,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
right: offset,
|
||||
bottom: offset,
|
||||
},
|
||||
],
|
||||
[dotColorStyle, offset, size],
|
||||
);
|
||||
return <View style={overlayStyle} />;
|
||||
function StatusDotOverlay({ dotColorStyle }: { dotColorStyle: ViewStyle }) {
|
||||
return <View style={[styles.statusDotOverlay, dotColorStyle]} />;
|
||||
}
|
||||
|
||||
function getStatusDotColorStyle(bucket: SidebarStateBucket) {
|
||||
@@ -569,24 +532,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusDotOverlay: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: STATUS_INDICATOR_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
standaloneStatusDot: {
|
||||
width: STATUS_INDICATOR_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_DOT_SIZE,
|
||||
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||
},
|
||||
standaloneRunningDot: {
|
||||
width: STATUS_INDICATOR_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
||||
},
|
||||
idleStatusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||
opacity: 0.3,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text } from "react-native";
|
||||
import { View, Text, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -264,6 +264,7 @@ function WorkspaceRowBody({
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowBodyProps) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const draggable = Boolean(drag);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
@@ -284,6 +285,17 @@ function WorkspaceRowBody({
|
||||
}
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
const handleWorkspacePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
if (draggable) interaction.handlePressIn(event);
|
||||
},
|
||||
[draggable, interaction],
|
||||
);
|
||||
const handleWorkspacePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
if (draggable) interaction.handlePressOut();
|
||||
}, [draggable, interaction]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
|
||||
@@ -292,7 +304,12 @@ function WorkspaceRowBody({
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
|
||||
const workspaceRowStyle = getWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
});
|
||||
return (
|
||||
<View
|
||||
{...(draggable ? dragAttributes : {})}
|
||||
@@ -325,10 +342,10 @@ function WorkspaceRowBody({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowHovered}
|
||||
onPressIn={draggable ? interaction.handlePressIn : undefined}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={handleWorkspacePressIn}
|
||||
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
|
||||
onPressOut={draggable ? interaction.handlePressOut : undefined}
|
||||
onPressOut={handleWorkspacePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -336,7 +353,7 @@ function WorkspaceRowBody({
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
@@ -458,18 +475,21 @@ function WorkspaceRowTrailingActions({
|
||||
|
||||
function getWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
return [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -495,6 +515,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
workspaceRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
workspaceRowDragging: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -710,7 +710,7 @@ function DragOverlayTabChipInner({
|
||||
|
||||
return (
|
||||
<View style={chipStyle}>
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} />
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
|
||||
<Text numberOfLines={1} style={chipLabelStyle}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnUI } from "react-native-worklets";
|
||||
import { getStatusRingRotation } from "@/components/status-ring/geometry";
|
||||
|
||||
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
|
||||
const activeRingCount = makeMutable(0);
|
||||
const clockRunning = makeMutable(false);
|
||||
|
||||
function advanceSharedRotation(): void {
|
||||
"worklet";
|
||||
if (activeRingCount.value === 0) {
|
||||
clockRunning.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
sharedRotation.value = getStatusRingRotation(Date.now());
|
||||
requestAnimationFrame(advanceSharedRotation);
|
||||
}
|
||||
|
||||
function registerStatusRing(registered: SharedValue<boolean>): void {
|
||||
"worklet";
|
||||
if (registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = true;
|
||||
activeRingCount.value += 1;
|
||||
|
||||
if (!clockRunning.value) {
|
||||
clockRunning.value = true;
|
||||
sharedRotation.value = getStatusRingRotation(Date.now());
|
||||
requestAnimationFrame(advanceSharedRotation);
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterStatusRing(registered: SharedValue<boolean>): void {
|
||||
"worklet";
|
||||
if (!registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = false;
|
||||
activeRingCount.value -= 1;
|
||||
}
|
||||
|
||||
export function useStatusRingRotation(): SharedValue<number> {
|
||||
const registered = useSharedValue(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scheduleOnUI(registerStatusRing, registered);
|
||||
return () => {
|
||||
scheduleOnUI(unregisterStatusRing, registered);
|
||||
};
|
||||
}, [registered]);
|
||||
|
||||
return sharedRotation;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
|
||||
import type { View as NativeView } from "react-native";
|
||||
import { STATUS_RING_PERIOD_MS } from "@/components/status-ring/geometry";
|
||||
|
||||
const STATUS_RING_KEYFRAMES: PropertyIndexedKeyframes = {
|
||||
transform: ["rotate(0deg)", "rotate(360deg)"],
|
||||
};
|
||||
const STATUS_RING_TIMING: KeyframeAnimationOptions = {
|
||||
duration: STATUS_RING_PERIOD_MS,
|
||||
easing: "linear",
|
||||
iterations: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
const STATUS_RING_TIMELINE_START_MS = 0;
|
||||
|
||||
export function useStatusRingAnimationRef(): RefCallback<NativeView> {
|
||||
const arcElement = useRef<HTMLElement | null>(null);
|
||||
const setArcElement = useCallback((instance: NativeView | null) => {
|
||||
arcElement.current = instance instanceof HTMLElement ? instance : null;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = arcElement.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animation = element.animate(STATUS_RING_KEYFRAMES, STATUS_RING_TIMING);
|
||||
animation.startTime = STATUS_RING_TIMELINE_START_MS;
|
||||
return () => {
|
||||
animation.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return setArcElement;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet as RNStyleSheet, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import {
|
||||
STATUS_RING_FRAME_SIZE,
|
||||
STATUS_RING_HEAD_OPACITY,
|
||||
STATUS_RING_SIZE,
|
||||
STATUS_RING_STROKE,
|
||||
STATUS_RING_TRACK_OPACITY,
|
||||
} from "@/components/status-ring/geometry";
|
||||
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
|
||||
|
||||
export interface StatusRingProps {
|
||||
// The surface the ring is drawn on top of, named rather than resolved, because theme colours
|
||||
// are only legible inside `StyleSheet.create` — see docs/unistyles.md. The frame fills itself
|
||||
// with this so the ring reads as a hole punched in whatever it overlaps: the agent icon under a
|
||||
// tab dot, the project tile under a sidebar badge. Leave it out where the ring sits on flat
|
||||
// background and has nothing to knock out.
|
||||
backdrop?: SurfaceBackdrop | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The static half of the running indicator: the knockout, the track, and the centre dot. The
|
||||
* platform entry points supply the rotating quarter and own only how it is driven.
|
||||
*
|
||||
* The ring has no colour prop. It only ever means one thing — an agent is running — so it reads
|
||||
* that one colour out of the theme itself. Passing the colour in would materialise it at the call
|
||||
* site, outside anything Unistyles tracks, and leave the mark in the old theme until something
|
||||
* incidental re-rendered the row.
|
||||
*/
|
||||
export function StatusRingFrame({ backdrop, children }: StatusRingProps & { children: ReactNode }) {
|
||||
return (
|
||||
<View style={[styles.frame, getBackdropStyle(backdrop)]}>
|
||||
<View style={styles.track} />
|
||||
{children}
|
||||
<View style={styles.centerDot} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getBackdropStyle(backdrop: SurfaceBackdrop | null | undefined) {
|
||||
switch (backdrop) {
|
||||
case "surface0":
|
||||
return styles.backdropSurface0;
|
||||
case "surface1":
|
||||
return styles.backdropSurface1;
|
||||
case "surfaceSidebar":
|
||||
return styles.backdropSurfaceSidebar;
|
||||
case "surfaceSidebarHover":
|
||||
return styles.backdropSurfaceSidebarHover;
|
||||
case "surface2":
|
||||
return styles.backdropSurface2;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The rotating quarter's geometry, deliberately plain React Native rather than Unistyles: on
|
||||
// native this style lands on a Reanimated `Animated.View`, and applying a theme-tracked style to
|
||||
// one crashes when the theme changes — Unistyles and Reanimated both mutate the same native node.
|
||||
// See docs/unistyles.md. The colour it needs lives on `styles.arc`, one level in, on a view
|
||||
// Reanimated never touches.
|
||||
export const rotatorStyles = RNStyleSheet.create({
|
||||
rotator: {
|
||||
position: "absolute",
|
||||
width: STATUS_RING_SIZE,
|
||||
height: STATUS_RING_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
export const styles = StyleSheet.create((theme) => {
|
||||
const circle = {
|
||||
width: STATUS_RING_SIZE,
|
||||
height: STATUS_RING_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: STATUS_RING_STROKE,
|
||||
} as const;
|
||||
const runningColor = getStatusDotColor({ theme, bucket: "running" }) ?? undefined;
|
||||
|
||||
return {
|
||||
frame: {
|
||||
width: STATUS_RING_FRAME_SIZE,
|
||||
height: STATUS_RING_FRAME_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
backdropSurface0: { backgroundColor: theme.colors.surface0 },
|
||||
backdropSurface1: { backgroundColor: theme.colors.surface1 },
|
||||
backdropSurfaceSidebar: { backgroundColor: theme.colors.surfaceSidebar },
|
||||
backdropSurfaceSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
|
||||
backdropSurface2: { backgroundColor: theme.colors.surface2 },
|
||||
|
||||
// The closed ring the quarter runs on. Same colour rather than a grey so the indicator is one
|
||||
// object at one hue; the opacity is what puts it behind the quarter instead of competing.
|
||||
track: {
|
||||
...circle,
|
||||
position: "absolute",
|
||||
borderColor: runningColor,
|
||||
opacity: STATUS_RING_TRACK_OPACITY,
|
||||
},
|
||||
|
||||
// The moving quarter. Three sides transparent leaves the top border alone, which on a fully
|
||||
// rounded box is a 90° arc — the edge that makes the rotation legible against the track.
|
||||
arc: {
|
||||
...circle,
|
||||
borderColor: "transparent",
|
||||
borderTopColor: runningColor,
|
||||
opacity: STATUS_RING_HEAD_OPACITY,
|
||||
},
|
||||
|
||||
centerDot: {
|
||||
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: runningColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
|
||||
|
||||
// The running indicator is a status dot with a rotating ring around it, and the whole set of
|
||||
// numbers below is a budget rather than four independent choices. The centre matches every other
|
||||
// filled status circle; the gap and stroke grow outward from it without changing that shared size.
|
||||
export const STATUS_RING_GAP = 1.5;
|
||||
export const STATUS_RING_STROKE = 1.5;
|
||||
// Derived outward from the dot, which means the dot is not a free variable: shrink it without
|
||||
// opening the gap by the same amount and the ring closes in behind it, the whole mark scales
|
||||
// down, and nothing about how the centre reads against the ring actually changes.
|
||||
export const STATUS_RING_SIZE =
|
||||
STATUS_INDICATOR_FILLED_DOT_SIZE + (STATUS_RING_GAP + STATUS_RING_STROKE) * 2;
|
||||
|
||||
// The knockout margin, and the reason the frame is wider than the ring. These indicators sit on
|
||||
// the bottom-right corner of an agent or project icon, and a dot only stays readable there
|
||||
// because it carries a ring of surface colour separating it from the art underneath. A ring drawn
|
||||
// out to the frame's edge has no such separation and collides with the icon, so the frame is the
|
||||
// knockout and the ring lives inside it.
|
||||
export const STATUS_RING_HALO_INSET = 1;
|
||||
export const STATUS_RING_FRAME_SIZE = STATUS_RING_SIZE + STATUS_RING_HALO_INSET * 2;
|
||||
|
||||
// The track and the quarter riding it are the same color and differ only here. The track is low
|
||||
// enough that the quarter is unmistakably the thing moving, high enough that the ring still reads
|
||||
// as a ring when the quarter is on the far side of it. The quarter is held off full so the centre
|
||||
// dot stays the solid part of the mark — the ring is what the dot is doing, not a second dot.
|
||||
export const STATUS_RING_TRACK_OPACITY = 0.3;
|
||||
export const STATUS_RING_HEAD_OPACITY = 0.9;
|
||||
|
||||
// One period for every ring in the app. Each platform owns one mount-independent timeline: web
|
||||
// animations share an absolute document-timeline start, while native publishes this wall-clock
|
||||
// phase from one UI-thread clock. A row that appears mid-flight therefore lands in step with the
|
||||
// rings already turning.
|
||||
export const STATUS_RING_PERIOD_MS = 900;
|
||||
|
||||
export function getStatusRingRotation(nowMs: number): number {
|
||||
"worklet";
|
||||
return ((nowMs % STATUS_RING_PERIOD_MS) / STATUS_RING_PERIOD_MS) * 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchors a corner status dot's offset for the ring that replaces it.
|
||||
*
|
||||
* The dots this replaces are pinned to an icon's bottom-right corner rather than centred in a
|
||||
* slot, so growing them in place would walk the indicator off the corner. Callers keep their
|
||||
* existing dot offset and pass it through here to grow around the same centre point.
|
||||
*/
|
||||
export function getStatusRingOffset(dotOffset: number, dotSize: number): number {
|
||||
return dotOffset - (STATUS_RING_FRAME_SIZE - dotSize) / 2;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { memo } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import {
|
||||
StatusRingFrame,
|
||||
type StatusRingProps,
|
||||
rotatorStyles,
|
||||
styles,
|
||||
} from "@/components/status-ring/frame";
|
||||
import { useStatusRingRotation } from "@/components/status-ring/clock";
|
||||
|
||||
/**
|
||||
* Native running indicator. The rotation is published by one shared UI-thread clock rather than
|
||||
* per-instance timing, so a ring that mounts mid-flight is already in phase — see `clock.ts`.
|
||||
*
|
||||
* The rotated view carries no theme-tracked style; the coloured arc is nested inside it. Putting
|
||||
* a Unistyles style on a Reanimated view crashes on theme change (docs/unistyles.md).
|
||||
*/
|
||||
export const StatusRing = memo(function StatusRing({ backdrop }: StatusRingProps) {
|
||||
const rotation = useStatusRingRotation();
|
||||
const rotatorStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotation.value}deg` }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<StatusRingFrame backdrop={backdrop}>
|
||||
<Animated.View style={[rotatorStyles.rotator, rotatorStyle]}>
|
||||
<View style={styles.arc} />
|
||||
</Animated.View>
|
||||
</StatusRingFrame>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { memo } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
StatusRingFrame,
|
||||
type StatusRingProps,
|
||||
rotatorStyles,
|
||||
styles,
|
||||
} from "@/components/status-ring/frame";
|
||||
import { useStatusRingAnimationRef } from "@/components/status-ring/clock.web";
|
||||
|
||||
/**
|
||||
* Web running indicator. A sidebar full of agents can hold dozens of these at once, so the browser
|
||||
* drives the rotation from one absolute document-timeline epoch rather than from mount time — see
|
||||
* `clock.web.ts`.
|
||||
*/
|
||||
export const StatusRing = memo(function StatusRing({ backdrop }: StatusRingProps) {
|
||||
const rotatorRef = useStatusRingAnimationRef();
|
||||
|
||||
return (
|
||||
<StatusRingFrame backdrop={backdrop}>
|
||||
<View ref={rotatorRef} style={rotatorStyles.rotator}>
|
||||
<View style={styles.arc} />
|
||||
</View>
|
||||
</StatusRingFrame>
|
||||
);
|
||||
});
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
createDesktopWebSocketTransportFactory,
|
||||
} from "./desktop-daemon-transport";
|
||||
import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport";
|
||||
import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc";
|
||||
|
||||
const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock";
|
||||
@@ -46,25 +43,4 @@ describe("desktop-daemon-transport", () => {
|
||||
expect(rpc.closedSessions).toEqual(["local-session-2"]);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards remote WebSocket headers and protocols through the desktop bridge", () => {
|
||||
const rpc = createFakeLocalDaemonTransportRpc();
|
||||
const transportFactory = createDesktopWebSocketTransportFactory(rpc);
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
transportFactory!({
|
||||
url: "wss://daemon.example/ws",
|
||||
headers: { "X-Tenant": "acme", Authorization: "Bearer secret" },
|
||||
protocols: ["paseo.bearer.secret"],
|
||||
});
|
||||
|
||||
expect(rpc.openCalls).toEqual([
|
||||
{
|
||||
transportType: "websocket",
|
||||
url: "wss://daemon.example/ws",
|
||||
headers: { "X-Tenant": "acme", Authorization: "Bearer secret" },
|
||||
protocols: ["paseo.bearer.secret"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,11 @@ import type {
|
||||
DaemonTransport,
|
||||
DaemonTransportFactory,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { DesktopDaemonTransportTarget, LocalTransportTarget } from "./desktop-daemon";
|
||||
import type { LocalTransportTarget } from "./desktop-daemon";
|
||||
import {
|
||||
defaultLocalDaemonTransportRpc,
|
||||
defaultWebSocketDaemonTransportRpc,
|
||||
type LocalDaemonTransportRpc,
|
||||
} from "./local-daemon-transport-rpc";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
|
||||
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
|
||||
|
||||
@@ -52,12 +50,11 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
|
||||
};
|
||||
}
|
||||
|
||||
function createDesktopDaemonTransportFactory(
|
||||
resolveTarget: (options: Parameters<DaemonTransportFactory>[0]) => DesktopDaemonTransportTarget,
|
||||
rpc: LocalDaemonTransportRpc,
|
||||
): DaemonTransportFactory {
|
||||
return (options) => {
|
||||
const target = resolveTarget(options);
|
||||
export function createDesktopLocalDaemonTransportFactory(
|
||||
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
|
||||
): DaemonTransportFactory | null {
|
||||
return ({ url }) => {
|
||||
const target = parseLocalDaemonTransportUrl(url);
|
||||
let sessionId: string | null = null;
|
||||
let unlisten: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
@@ -190,26 +187,3 @@ function createDesktopDaemonTransportFactory(
|
||||
return transport;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopLocalDaemonTransportFactory(
|
||||
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
|
||||
): DaemonTransportFactory | null {
|
||||
return createDesktopDaemonTransportFactory(({ url }) => parseLocalDaemonTransportUrl(url), rpc);
|
||||
}
|
||||
|
||||
export function createDesktopWebSocketTransportFactory(
|
||||
rpc?: LocalDaemonTransportRpc,
|
||||
): DaemonTransportFactory | null {
|
||||
if (!rpc && !isElectronRuntime()) {
|
||||
return null;
|
||||
}
|
||||
return createDesktopDaemonTransportFactory(
|
||||
({ url, headers, protocols }) => ({
|
||||
transportType: "websocket" as const,
|
||||
url,
|
||||
...(headers && Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(protocols && protocols.length > 0 ? { protocols } : {}),
|
||||
}),
|
||||
rpc ?? defaultWebSocketDaemonTransportRpc,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,16 +46,6 @@ export interface LocalTransportTarget {
|
||||
transportPath: string;
|
||||
}
|
||||
|
||||
export interface WebSocketTransportTarget {
|
||||
[key: string]: unknown;
|
||||
transportType: "websocket";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
export type DesktopDaemonTransportTarget = LocalTransportTarget | WebSocketTransportTarget;
|
||||
|
||||
interface LocalTransportEventPayload {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
@@ -203,16 +193,6 @@ export async function openLocalTransportSession(target: LocalTransportTarget): P
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function openWebSocketTransportSession(
|
||||
target: WebSocketTransportTarget,
|
||||
): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>("open_websocket_daemon_transport", target);
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
throw new Error("Unexpected WebSocket transport session response.");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function sendLocalTransportMessage(input: {
|
||||
sessionId: string;
|
||||
text?: string;
|
||||
|
||||
@@ -2,10 +2,8 @@ import {
|
||||
closeLocalTransportSession,
|
||||
listenToLocalTransportEvents,
|
||||
openLocalTransportSession,
|
||||
openWebSocketTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
type DesktopDaemonTransportTarget,
|
||||
type WebSocketTransportTarget,
|
||||
type LocalTransportTarget,
|
||||
} from "./desktop-daemon";
|
||||
|
||||
export interface LocalDaemonTransportEvent {
|
||||
@@ -19,7 +17,7 @@ export interface LocalDaemonTransportEvent {
|
||||
}
|
||||
|
||||
export interface LocalDaemonTransportRpc {
|
||||
openSession(target: DesktopDaemonTransportTarget): Promise<string>;
|
||||
openSession(target: LocalTransportTarget): Promise<string>;
|
||||
listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>;
|
||||
sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>;
|
||||
closeSession(sessionId: string): Promise<void>;
|
||||
@@ -31,10 +29,3 @@ export const defaultLocalDaemonTransportRpc: LocalDaemonTransportRpc = {
|
||||
sendMessage: sendLocalTransportMessage,
|
||||
closeSession: closeLocalTransportSession,
|
||||
};
|
||||
|
||||
export const defaultWebSocketDaemonTransportRpc: LocalDaemonTransportRpc = {
|
||||
openSession: (target) => openWebSocketTransportSession(target as WebSocketTransportTarget),
|
||||
listenToEvents: listenToLocalTransportEvents,
|
||||
sendMessage: sendLocalTransportMessage,
|
||||
closeSession: closeLocalTransportSession,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DesktopDaemonTransportTarget } from "./desktop-daemon";
|
||||
import type { LocalTransportTarget } from "./desktop-daemon";
|
||||
import type {
|
||||
LocalDaemonTransportEvent,
|
||||
LocalDaemonTransportRpc,
|
||||
@@ -11,7 +11,7 @@ export interface RecordedSend {
|
||||
}
|
||||
|
||||
export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
|
||||
readonly openCalls: DesktopDaemonTransportTarget[];
|
||||
readonly openCalls: LocalTransportTarget[];
|
||||
readonly recordedSends: RecordedSend[];
|
||||
readonly closedSessions: string[];
|
||||
resolveOpen(sessionId: string): void;
|
||||
@@ -22,7 +22,7 @@ export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
|
||||
}
|
||||
|
||||
export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc {
|
||||
const openCalls: DesktopDaemonTransportTarget[] = [];
|
||||
const openCalls: LocalTransportTarget[] = [];
|
||||
const recordedSends: RecordedSend[] = [];
|
||||
const closedSessions: string[] = [];
|
||||
let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null;
|
||||
|
||||
@@ -1426,19 +1426,6 @@ export const ar: TranslationResources = {
|
||||
show: "عرض متقدم",
|
||||
hide: "إخفاء المتقدمة",
|
||||
},
|
||||
headers: {
|
||||
title: "رؤوس مخصصة",
|
||||
add: "إضافة رأس",
|
||||
name: "الاسم",
|
||||
value: "القيمة",
|
||||
remove: "إزالة الرأس",
|
||||
errors: {
|
||||
missingName: "أدخل اسمًا لكل رأس",
|
||||
invalidName: "{{name}} ليس اسم رأس صالحًا",
|
||||
invalidValue: "يحتوي {{name}} على فاصل أسطر غير صالح",
|
||||
duplicateName: "تم إدخال {{name}} أكثر من مرة",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "إظهار كلمة المرور",
|
||||
hide: "إخفاء كلمة المرور",
|
||||
|
||||
@@ -1437,19 +1437,6 @@ export const en = {
|
||||
show: "Show advanced",
|
||||
hide: "Hide advanced",
|
||||
},
|
||||
headers: {
|
||||
title: "Custom headers",
|
||||
add: "Add header",
|
||||
name: "Name",
|
||||
value: "Value",
|
||||
remove: "Remove header",
|
||||
errors: {
|
||||
missingName: "Enter a name for each header",
|
||||
invalidName: "{{name}} is not a valid header name",
|
||||
invalidValue: "{{name}} contains an invalid line break",
|
||||
duplicateName: "{{name}} is entered more than once",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Show password",
|
||||
hide: "Hide password",
|
||||
|
||||
@@ -1467,19 +1467,6 @@ export const es: TranslationResources = {
|
||||
show: "Mostrar avanzado",
|
||||
hide: "Ocultar avanzado",
|
||||
},
|
||||
headers: {
|
||||
title: "Encabezados personalizados",
|
||||
add: "Añadir encabezado",
|
||||
name: "Nombre",
|
||||
value: "Valor",
|
||||
remove: "Eliminar encabezado",
|
||||
errors: {
|
||||
missingName: "Introduce un nombre para cada encabezado",
|
||||
invalidName: "{{name}} no es un nombre de encabezado válido",
|
||||
invalidValue: "{{name}} contiene un salto de línea no válido",
|
||||
duplicateName: "{{name}} se ha introducido más de una vez",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Mostrar contraseña",
|
||||
hide: "Ocultar contraseña",
|
||||
|
||||
@@ -1471,19 +1471,6 @@ export const fr: TranslationResources = {
|
||||
show: "Afficher avancé",
|
||||
hide: "Masquer avancé",
|
||||
},
|
||||
headers: {
|
||||
title: "En-têtes personnalisés",
|
||||
add: "Ajouter un en-tête",
|
||||
name: "Nom",
|
||||
value: "Valeur",
|
||||
remove: "Supprimer l'en-tête",
|
||||
errors: {
|
||||
missingName: "Saisissez un nom pour chaque en-tête",
|
||||
invalidName: "{{name}} n'est pas un nom d'en-tête valide",
|
||||
invalidValue: "{{name}} contient un saut de ligne non valide",
|
||||
duplicateName: "{{name}} est saisi plusieurs fois",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Afficher le mot de passe",
|
||||
hide: "Masquer le mot de passe",
|
||||
|
||||
@@ -1440,19 +1440,6 @@ export const ja: TranslationResources = {
|
||||
show: "詳細設定を表示",
|
||||
hide: "詳細設定を非表示",
|
||||
},
|
||||
headers: {
|
||||
title: "カスタムヘッダー",
|
||||
add: "ヘッダーを追加",
|
||||
name: "名前",
|
||||
value: "値",
|
||||
remove: "ヘッダーを削除",
|
||||
errors: {
|
||||
missingName: "各ヘッダーの名前を入力してください",
|
||||
invalidName: "{{name}} は有効なヘッダー名ではありません",
|
||||
invalidValue: "{{name}} に無効な改行が含まれています",
|
||||
duplicateName: "{{name}} が複数回入力されています",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "パスワードを表示",
|
||||
hide: "パスワードを非表示",
|
||||
|
||||
@@ -1436,19 +1436,6 @@ export const ko: TranslationResources = {
|
||||
show: "고급 표시",
|
||||
hide: "고급 숨기기",
|
||||
},
|
||||
headers: {
|
||||
title: "사용자 지정 헤더",
|
||||
add: "헤더 추가",
|
||||
name: "이름",
|
||||
value: "값",
|
||||
remove: "헤더 삭제",
|
||||
errors: {
|
||||
missingName: "각 헤더의 이름을 입력하세요",
|
||||
invalidName: "{{name}}은(는) 올바른 헤더 이름이 아닙니다",
|
||||
invalidValue: "{{name}}에 잘못된 줄바꿈이 있습니다",
|
||||
duplicateName: "{{name}}이(가) 두 번 이상 입력되었습니다",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "비밀번호 표시",
|
||||
hide: "비밀번호 숨기기",
|
||||
|
||||
@@ -1453,19 +1453,6 @@ export const ptBR: TranslationResources = {
|
||||
show: "Mostrar avançado",
|
||||
hide: "Ocultar avançado",
|
||||
},
|
||||
headers: {
|
||||
title: "Cabeçalhos personalizados",
|
||||
add: "Adicionar cabeçalho",
|
||||
name: "Nome",
|
||||
value: "Valor",
|
||||
remove: "Remover cabeçalho",
|
||||
errors: {
|
||||
missingName: "Digite um nome para cada cabeçalho",
|
||||
invalidName: "{{name}} não é um nome de cabeçalho válido",
|
||||
invalidValue: "{{name}} contém uma quebra de linha inválida",
|
||||
duplicateName: "{{name}} foi inserido mais de uma vez",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Mostrar senha",
|
||||
hide: "Ocultar senha",
|
||||
|
||||
@@ -1458,19 +1458,6 @@ export const ru: TranslationResources = {
|
||||
show: "Показать расширенные",
|
||||
hide: "Скрыть расширенные",
|
||||
},
|
||||
headers: {
|
||||
title: "Пользовательские заголовки",
|
||||
add: "Добавить заголовок",
|
||||
name: "Имя",
|
||||
value: "Значение",
|
||||
remove: "Удалить заголовок",
|
||||
errors: {
|
||||
missingName: "Введите имя для каждого заголовка",
|
||||
invalidName: "{{name}} — недопустимое имя заголовка",
|
||||
invalidValue: "{{name}} содержит недопустимый перенос строки",
|
||||
duplicateName: "{{name}} введён несколько раз",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Показать пароль",
|
||||
hide: "Скрыть пароль",
|
||||
|
||||
@@ -1409,19 +1409,6 @@ export const zhCN: TranslationResources = {
|
||||
show: "显示高级选项",
|
||||
hide: "隐藏高级选项",
|
||||
},
|
||||
headers: {
|
||||
title: "自定义请求头",
|
||||
add: "添加请求头",
|
||||
name: "名称",
|
||||
value: "值",
|
||||
remove: "删除请求头",
|
||||
errors: {
|
||||
missingName: "请为每个请求头输入名称",
|
||||
invalidName: "{{name}} 不是有效的请求头名称",
|
||||
invalidValue: "{{name}} 包含无效换行符",
|
||||
duplicateName: "{{name}} 输入了多次",
|
||||
},
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "显示密码",
|
||||
hide: "隐藏密码",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { formatShortcut } from "@/utils/format-shortcut";
|
||||
import {
|
||||
buildKeyboardShortcutHelpSections,
|
||||
buildEffectiveBindings,
|
||||
@@ -645,7 +646,11 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
"workspace-jump-index": ["mod", "1-9"],
|
||||
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
|
||||
"workspace-tab-close-current": ["meta", "W"],
|
||||
// Derived from `combo: "Cmd+W"`, so the token is `mod` where the row
|
||||
// used to be hand-authored as `meta`. This binding is mac-only and
|
||||
// `formatShortcut` renders both as ⌘, so the badge is unchanged — see
|
||||
// the render assertion below.
|
||||
"workspace-tab-close-current": ["mod", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
},
|
||||
@@ -659,11 +664,15 @@ describe("keyboard-shortcut help sections", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses mod+b for the left sidebar and mod+period for both sidebars on non-mac",
|
||||
name: "uses ctrl+b for the left sidebar and ctrl+period for both sidebars on non-mac",
|
||||
context: { isMac: false, isDesktop: false },
|
||||
// Derived from `combo: "Ctrl+B"` / `"Ctrl+."`, so the token is `ctrl` where
|
||||
// these rows used to be hand-authored as `mod`. Both bindings are non-mac
|
||||
// only and `formatShortcut` labels either token "Ctrl" there, so the badge
|
||||
// is unchanged — see the render assertion below.
|
||||
expectedKeys: {
|
||||
"toggle-left-sidebar": ["mod", "B"],
|
||||
"toggle-both-sidebars": ["mod", "."],
|
||||
"toggle-left-sidebar": ["ctrl", "B"],
|
||||
"toggle-both-sidebars": ["ctrl", "."],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -672,10 +681,104 @@ describe("keyboard-shortcut help sections", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections(context);
|
||||
|
||||
for (const [id, keys] of Object.entries(expectedKeys)) {
|
||||
expect(findRow(sections, id)?.keys).toEqual(keys);
|
||||
expect(findRow(sections, id)?.chord).toEqual([keys]);
|
||||
}
|
||||
});
|
||||
|
||||
describe("rows derive their keys from the binding that fires", () => {
|
||||
const macDesktop = { isMac: true, isDesktop: true };
|
||||
const NEW_WORKSPACE_BINDING = "workspace-new-cmd-n-mac";
|
||||
const MAC_INDEX_BINDING = "workspace-navigate-index-cmd-digit-mac";
|
||||
const PANE_FOCUS_LEFT_BINDING = "workspace-pane-focus-left-cmd-shift-left";
|
||||
const SHOW_SHORTCUTS_BINDING = "shortcuts-dialog-toggle-question-mark";
|
||||
|
||||
function rowChord(overrides: ShortcutOverrides, id: string) {
|
||||
const sections = buildKeyboardShortcutHelpSections(
|
||||
macDesktop,
|
||||
buildEffectiveBindings(overrides),
|
||||
);
|
||||
return findRow(sections, id)?.chord ?? null;
|
||||
}
|
||||
|
||||
// The reported bug: the cheat sheet advertised the shipped default no matter
|
||||
// what the user had rebound the shortcut to.
|
||||
it("shows the override, not the shipped default", () => {
|
||||
expect(rowChord({}, "new-workspace")).toEqual([["mod", "N"]]);
|
||||
expect(rowChord({ [NEW_WORKSPACE_BINDING]: "Cmd+Shift+K" }, "new-workspace")).toEqual([
|
||||
["mod", "shift", "K"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows every step of a multi-step chord override", () => {
|
||||
expect(rowChord({ [NEW_WORKSPACE_BINDING]: "Cmd+K Cmd+N" }, "new-workspace")).toEqual([
|
||||
["mod", "K"],
|
||||
["mod", "N"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports an unassigned row as having no keys at all", () => {
|
||||
expect(rowChord({ [NEW_WORKSPACE_BINDING]: UNASSIGNED_COMBO }, "new-workspace")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns to the shipped default once the override is cleared", () => {
|
||||
expect(rowChord({ [NEW_WORKSPACE_BINDING]: "Cmd+Shift+K" }, "new-workspace")).toEqual([
|
||||
["mod", "shift", "K"],
|
||||
]);
|
||||
expect(rowChord({}, "new-workspace")).toEqual([["mod", "N"]]);
|
||||
});
|
||||
|
||||
// An arrow override used to render as the raw `ARROWLEFT` code, because the
|
||||
// display path uppercased key names past the table that maps them to arrows.
|
||||
it("renders an arrow override as an arrow", () => {
|
||||
const chord = rowChord(
|
||||
{ [PANE_FOCUS_LEFT_BINDING]: "Cmd+Alt+ArrowLeft" },
|
||||
"workspace-pane-focus-left",
|
||||
);
|
||||
expect(chord).toEqual([["mod", "alt", "Left"]]);
|
||||
expect(formatShortcut(chord?.[0] ?? [], "mac")).toBe("⌥⌘←");
|
||||
});
|
||||
|
||||
// `1-9` and `?` are display-only tokens no combo string can spell, so these
|
||||
// two rows opt out of default derivation via `help.defaultDisplayKeys`.
|
||||
it("keeps the wildcard token on the index-jump row", () => {
|
||||
expect(rowChord({}, "workspace-jump-index")).toEqual([["mod", "1-9"]]);
|
||||
});
|
||||
|
||||
it("keeps the bare ? on the show-shortcuts row rather than deriving Shift+?", () => {
|
||||
expect(rowChord({}, "show-shortcuts")).toEqual([["?"]]);
|
||||
});
|
||||
|
||||
it("replaces the default-only wildcard when the index jump is rebound", () => {
|
||||
expect(rowChord({ [MAC_INDEX_BINDING]: "Ctrl+Digit" }, "workspace-jump-index")).toEqual([
|
||||
["ctrl", "Digit"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the default-only ? when show shortcuts is rebound", () => {
|
||||
expect(rowChord({ [SHOW_SHORTCUTS_BINDING]: "Cmd+K" }, "show-shortcuts")).toEqual([
|
||||
["mod", "K"],
|
||||
]);
|
||||
});
|
||||
|
||||
// The authored row said `meta`, the derived row says `mod`. Both render ⌘ on
|
||||
// mac and this binding is mac-only, so nothing the user sees moved.
|
||||
it("still renders close-tab as ⌘W after switching to the derived token", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections(macDesktop);
|
||||
const chord = findRow(sections, "workspace-tab-close-current")?.chord;
|
||||
expect(formatShortcut(chord?.[0] ?? [], "mac")).toBe("⌘W");
|
||||
});
|
||||
|
||||
// Same story on the other side: these rows said `mod`, the non-mac combos
|
||||
// say `Ctrl`, and non-mac labels both as "Ctrl".
|
||||
it("still renders the sidebar toggles as Ctrl+B and Ctrl+. on non-mac", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac: false, isDesktop: false });
|
||||
const left = findRow(sections, "toggle-left-sidebar")?.chord;
|
||||
const both = findRow(sections, "toggle-both-sidebars")?.chord;
|
||||
expect(formatShortcut(left?.[0] ?? [], "non-mac")).toBe("Ctrl+B");
|
||||
expect(formatShortcut(both?.[0] ?? [], "non-mac")).toBe("Ctrl+.");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns stable i18n keys for section titles and help rows", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true });
|
||||
const projects = sections.find((section) => section.id === "projects");
|
||||
@@ -889,9 +992,12 @@ describe("unassigned shortcuts", () => {
|
||||
});
|
||||
|
||||
describe("resolveShortcutKeysForAction", () => {
|
||||
// `ctrl`, not `mod`: these keys are derived from the non-mac binding's
|
||||
// `combo: "Ctrl+T"` rather than hand-authored. `formatShortcut` labels both
|
||||
// tokens "Ctrl" off mac, and this binding is non-mac only.
|
||||
it("returns the default keys when there is no override", () => {
|
||||
expect(resolveShortcutKeysForAction("workspace-tab-new", {}, desktopNonMac)).toEqual([
|
||||
["mod", "T"],
|
||||
["ctrl", "T"],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -924,13 +1030,13 @@ describe("unassigned shortcuts", () => {
|
||||
{ [TAB_NEW_BINDING]: "Ctrl+Nonsense" },
|
||||
desktopNonMac,
|
||||
),
|
||||
).toEqual([["mod", "T"]]);
|
||||
).toEqual([["ctrl", "T"]]);
|
||||
});
|
||||
|
||||
it("falls back to the default keys for a non-string stored value", () => {
|
||||
const overrides = { [TAB_NEW_BINDING]: 42 } as unknown as ShortcutOverrides;
|
||||
expect(resolveShortcutKeysForAction("workspace-tab-new", overrides, desktopNonMac)).toEqual([
|
||||
["mod", "T"],
|
||||
["ctrl", "T"],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -944,7 +1050,7 @@ describe("unassigned shortcuts", () => {
|
||||
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO });
|
||||
const sections = buildKeyboardShortcutHelpSections(desktopNonMac, bindings);
|
||||
|
||||
expect(findRow(sections, "workspace-tab-new")?.keys).toEqual([]);
|
||||
expect(findRow(sections, "workspace-tab-new")?.chord).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the row so the shortcut stays rebindable", () => {
|
||||
@@ -958,15 +1064,14 @@ describe("unassigned shortcuts", () => {
|
||||
it("still lists the default keys when nothing is overridden", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections(desktopNonMac);
|
||||
|
||||
expect(findRow(sections, "workspace-tab-new")?.keys).toEqual(["mod", "T"]);
|
||||
expect(getDefaultKeysForAction("workspace-tab-new", desktopNonMac)).toEqual(["mod", "T"]);
|
||||
expect(findRow(sections, "workspace-tab-new")?.chord).toEqual([["ctrl", "T"]]);
|
||||
expect(getDefaultKeysForAction("workspace-tab-new", desktopNonMac)).toEqual([["ctrl", "T"]]);
|
||||
});
|
||||
|
||||
// A binding authored with `combo: ""` has no default. The settings row uses
|
||||
// this to hide Reset, which would otherwise be a dead button landing on the
|
||||
// same "Not set" state Clear already produced. `help.keys` stays populated
|
||||
// on purpose: it is hand-authored, so the empty parsed chord alone has to be
|
||||
// what marks the binding default-less.
|
||||
// same "Not set" state Clear already produced. The empty parsed chord is what
|
||||
// marks the binding default-less.
|
||||
it("reports no default keys for a binding that ships without a combo", () => {
|
||||
const bindings = withoutDefaultCombo("workspace-tab-new");
|
||||
|
||||
@@ -977,7 +1082,7 @@ describe("unassigned shortcuts", () => {
|
||||
const bindings = withoutDefaultCombo("workspace-tab-new");
|
||||
const sections = buildKeyboardShortcutHelpSections(desktopNonMac, bindings);
|
||||
|
||||
expect(findRow(sections, "workspace-tab-new")?.keys).toEqual([]);
|
||||
expect(findRow(sections, "workspace-tab-new")?.chord).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,8 @@ export interface KeyboardShortcutHelpRow {
|
||||
id: string;
|
||||
label: string;
|
||||
labelKey: string;
|
||||
keys: ShortcutKey[];
|
||||
/** The keys that actually fire this action, or `null` when it has none. */
|
||||
chord: ShortcutKey[][] | null;
|
||||
note?: string;
|
||||
noteKey?: string;
|
||||
}
|
||||
@@ -88,7 +89,14 @@ interface ShortcutHelp {
|
||||
id: string;
|
||||
section: ShortcutSectionId;
|
||||
label: string;
|
||||
keys: ShortcutKey[];
|
||||
/**
|
||||
* Display keys to show instead of the combo. Set this only when the combo
|
||||
* cannot express what the row means — the `Digit` wildcard, which stands for
|
||||
* any of 1-9, and `Shift+?`, whose Shift is implied by the character itself.
|
||||
* Every other row derives its keys from `combo`, so a rebound shortcut shows
|
||||
* what it now does rather than what it shipped as.
|
||||
*/
|
||||
defaultDisplayKeys?: ShortcutKey[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -194,7 +202,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "new-agent",
|
||||
section: "projects",
|
||||
label: "Open project",
|
||||
keys: ["mod", "O"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -206,7 +213,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "new-agent",
|
||||
section: "projects",
|
||||
label: "Open project",
|
||||
keys: ["mod", "O"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -220,7 +226,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "new-workspace",
|
||||
section: "projects",
|
||||
label: "New workspace",
|
||||
keys: ["mod", "N"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -232,7 +237,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "new-workspace",
|
||||
section: "projects",
|
||||
label: "New workspace",
|
||||
keys: ["mod", "N"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -246,7 +250,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "switch-project",
|
||||
section: "projects",
|
||||
label: "Switch project",
|
||||
keys: ["mod", "P"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -258,7 +261,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "switch-project",
|
||||
section: "projects",
|
||||
label: "Switch project",
|
||||
keys: ["mod", "P"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -274,7 +276,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "archive-workspace",
|
||||
section: "projects",
|
||||
label: "Archive workspace",
|
||||
keys: ["mod", "shift", "Backspace"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -288,7 +289,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "archive-workspace",
|
||||
section: "projects",
|
||||
label: "Archive workspace",
|
||||
keys: ["mod", "shift", "Backspace"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -302,7 +302,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "pin-workspace",
|
||||
section: "projects",
|
||||
label: "Pin chat",
|
||||
keys: ["mod", "shift", "P"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -314,7 +313,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "pin-workspace",
|
||||
section: "projects",
|
||||
label: "Pin chat",
|
||||
keys: ["mod", "shift", "P"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -328,7 +326,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-new",
|
||||
section: "tabs-panes",
|
||||
label: "New tab",
|
||||
keys: ["mod", "T"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -340,7 +337,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-new",
|
||||
section: "tabs-panes",
|
||||
label: "New tab",
|
||||
keys: ["mod", "T"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -352,7 +348,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-close-current",
|
||||
section: "tabs-panes",
|
||||
label: "Close current tab",
|
||||
keys: ["meta", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -364,7 +359,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-close-current",
|
||||
section: "tabs-panes",
|
||||
label: "Close current tab",
|
||||
keys: ["ctrl", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -376,7 +370,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-close-current",
|
||||
section: "tabs-panes",
|
||||
label: "Close current tab",
|
||||
keys: ["alt", "shift", "W"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -391,7 +384,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-jump-index",
|
||||
section: "navigation",
|
||||
label: "Jump to workspace",
|
||||
keys: ["mod", "1-9"],
|
||||
defaultDisplayKeys: ["mod", "1-9"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -404,7 +397,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-jump-index",
|
||||
section: "navigation",
|
||||
label: "Jump to workspace",
|
||||
keys: ["mod", "1-9"],
|
||||
defaultDisplayKeys: ["mod", "1-9"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -417,7 +410,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-jump-index",
|
||||
section: "navigation",
|
||||
label: "Jump to workspace",
|
||||
keys: ["alt", "1-9"],
|
||||
defaultDisplayKeys: ["alt", "1-9"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -432,7 +425,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "navigation",
|
||||
label: "Jump to tab",
|
||||
keys: ["mod", "alt", "1-9"],
|
||||
defaultDisplayKeys: ["mod", "alt", "1-9"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -445,7 +438,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "navigation",
|
||||
label: "Jump to tab",
|
||||
keys: ["alt", "1-9"],
|
||||
defaultDisplayKeys: ["alt", "1-9"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -458,7 +451,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "navigation",
|
||||
label: "Jump to tab",
|
||||
keys: ["alt", "shift", "1-9"],
|
||||
defaultDisplayKeys: ["alt", "shift", "1-9"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -473,7 +466,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-prev",
|
||||
section: "navigation",
|
||||
label: "Previous workspace",
|
||||
keys: ["mod", "["],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -486,7 +478,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-prev",
|
||||
section: "navigation",
|
||||
label: "Previous workspace",
|
||||
keys: ["mod", "["],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -499,7 +490,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-next",
|
||||
section: "navigation",
|
||||
label: "Next workspace",
|
||||
keys: ["mod", "]"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -512,7 +502,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-next",
|
||||
section: "navigation",
|
||||
label: "Next workspace",
|
||||
keys: ["mod", "]"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -525,7 +514,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-prev",
|
||||
section: "navigation",
|
||||
label: "Previous workspace",
|
||||
keys: ["alt", "["],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -538,7 +526,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-next",
|
||||
section: "navigation",
|
||||
label: "Next workspace",
|
||||
keys: ["alt", "]"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -553,7 +540,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-prev",
|
||||
section: "navigation",
|
||||
label: "Previous tab",
|
||||
keys: ["alt", "shift", "["],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -566,7 +552,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-next",
|
||||
section: "navigation",
|
||||
label: "Next tab",
|
||||
keys: ["alt", "shift", "]"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -580,7 +565,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-split-right",
|
||||
section: "tabs-panes",
|
||||
label: "Split pane right",
|
||||
keys: ["mod", "\\"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -592,7 +576,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-split-down",
|
||||
section: "tabs-panes",
|
||||
label: "Split pane down",
|
||||
keys: ["mod", "shift", "\\"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -604,7 +587,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-focus-left",
|
||||
section: "tabs-panes",
|
||||
label: "Focus pane left",
|
||||
keys: ["mod", "shift", "Left"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -616,7 +598,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-focus-right",
|
||||
section: "tabs-panes",
|
||||
label: "Focus pane right",
|
||||
keys: ["mod", "shift", "Right"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -628,7 +609,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-focus-up",
|
||||
section: "tabs-panes",
|
||||
label: "Focus pane up",
|
||||
keys: ["mod", "shift", "Up"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -640,7 +620,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-focus-down",
|
||||
section: "tabs-panes",
|
||||
label: "Focus pane down",
|
||||
keys: ["mod", "shift", "Down"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -652,7 +631,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-move-tab-left",
|
||||
section: "tabs-panes",
|
||||
label: "Move tab left",
|
||||
keys: ["mod", "shift", "alt", "Left"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -664,7 +642,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-move-tab-right",
|
||||
section: "tabs-panes",
|
||||
label: "Move tab right",
|
||||
keys: ["mod", "shift", "alt", "Right"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -676,7 +653,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-move-tab-up",
|
||||
section: "tabs-panes",
|
||||
label: "Move tab up",
|
||||
keys: ["mod", "shift", "alt", "Up"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -688,7 +664,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-move-tab-down",
|
||||
section: "tabs-panes",
|
||||
label: "Move tab down",
|
||||
keys: ["mod", "shift", "alt", "Down"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -700,7 +675,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-close",
|
||||
section: "tabs-panes",
|
||||
label: "Close pane",
|
||||
keys: ["mod", "shift", "W"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -714,7 +688,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-terminal-new",
|
||||
section: "panels",
|
||||
label: "New terminal",
|
||||
keys: ["mod", "shift", "T"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -726,7 +699,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-terminal-new",
|
||||
section: "panels",
|
||||
label: "New terminal",
|
||||
keys: ["mod", "shift", "T"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -740,7 +712,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-command-center",
|
||||
section: "panels",
|
||||
label: "Toggle command center",
|
||||
keys: ["mod", "K"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -752,7 +723,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-command-center",
|
||||
section: "panels",
|
||||
label: "Toggle command center",
|
||||
keys: ["mod", "K"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -767,7 +737,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "show-shortcuts",
|
||||
section: "panels",
|
||||
label: "Show keyboard shortcuts",
|
||||
keys: ["?"],
|
||||
defaultDisplayKeys: ["?"],
|
||||
note: "Available when focus is not in a text field or terminal.",
|
||||
},
|
||||
},
|
||||
@@ -782,7 +752,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-left-sidebar",
|
||||
section: "panels",
|
||||
label: "Toggle left sidebar",
|
||||
keys: ["mod", "B"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -794,7 +763,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-left-sidebar",
|
||||
section: "panels",
|
||||
label: "Toggle left sidebar",
|
||||
keys: ["mod", "B"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -806,7 +774,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-right-sidebar",
|
||||
section: "panels",
|
||||
label: "Toggle right sidebar",
|
||||
keys: ["mod", "E"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -818,7 +785,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-right-sidebar",
|
||||
section: "panels",
|
||||
label: "Toggle right sidebar",
|
||||
keys: ["mod", "E"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -838,7 +804,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-both-sidebars",
|
||||
section: "panels",
|
||||
label: "Toggle both sidebars",
|
||||
keys: ["mod", "."],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -850,7 +815,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-both-sidebars",
|
||||
section: "panels",
|
||||
label: "Toggle both sidebars",
|
||||
keys: ["mod", "."],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -864,7 +828,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-settings",
|
||||
section: "panels",
|
||||
label: "Toggle settings",
|
||||
keys: ["mod", ","],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -876,7 +839,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-settings",
|
||||
section: "panels",
|
||||
label: "Toggle settings",
|
||||
keys: ["mod", ","],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -890,7 +852,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-focus",
|
||||
section: "panels",
|
||||
label: "Toggle focus mode",
|
||||
keys: ["mod", "shift", "F"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -902,7 +863,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "toggle-focus",
|
||||
section: "panels",
|
||||
label: "Toggle focus mode",
|
||||
keys: ["mod", "shift", "F"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -916,7 +876,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "cycle-theme",
|
||||
section: "panels",
|
||||
label: "Cycle theme",
|
||||
keys: ["mod", "alt", "T"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -928,7 +887,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "cycle-theme",
|
||||
section: "panels",
|
||||
label: "Cycle theme",
|
||||
keys: ["mod", "alt", "T"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -943,7 +901,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "focus-message-input",
|
||||
section: "agent-input",
|
||||
label: "Focus message input",
|
||||
keys: ["mod", "L"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -956,7 +913,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "focus-message-input",
|
||||
section: "agent-input",
|
||||
label: "Focus message input",
|
||||
keys: ["mod", "L"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -970,7 +926,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "cycle-agent-mode",
|
||||
section: "agent-input",
|
||||
label: "Cycle agent mode",
|
||||
keys: ["shift", "Tab"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -984,7 +939,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "voice-toggle",
|
||||
section: "agent-input",
|
||||
label: "Toggle voice mode",
|
||||
keys: ["mod", "shift", "D"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -998,7 +952,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "voice-toggle",
|
||||
section: "agent-input",
|
||||
label: "Toggle voice mode",
|
||||
keys: ["mod", "shift", "D"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1011,7 +964,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "dictation-toggle",
|
||||
section: "agent-input",
|
||||
label: "Start/stop dictation",
|
||||
keys: ["mod", "D"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1024,7 +976,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "dictation-toggle",
|
||||
section: "agent-input",
|
||||
label: "Start/stop dictation",
|
||||
keys: ["mod", "D"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1038,7 +989,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "agent-interrupt",
|
||||
section: "agent-input",
|
||||
label: "Interrupt agent",
|
||||
keys: ["Esc"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1060,7 +1010,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "voice-mute-toggle",
|
||||
section: "agent-input",
|
||||
label: "Mute/unmute voice mode",
|
||||
keys: ["Space"],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1123,7 +1072,11 @@ export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShor
|
||||
if (binding.repeat === false && lastCombo) {
|
||||
lastCombo.repeat = false;
|
||||
}
|
||||
return { ...binding, combo: override, parsedChord };
|
||||
if (!binding.help?.defaultDisplayKeys) {
|
||||
return { ...binding, combo: override, parsedChord };
|
||||
}
|
||||
const { defaultDisplayKeys: _defaultDisplayKeys, ...help } = binding.help;
|
||||
return { ...binding, combo: override, parsedChord, help };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1422,11 +1375,33 @@ export function getBindingIdForAction(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys to display for one binding, derived from the combo that actually
|
||||
* fires so a rebound shortcut never advertises the keys it shipped with.
|
||||
*
|
||||
* `help.defaultDisplayKeys` wins where it is set, but only on the shipped
|
||||
* binding. Effective bindings discard it when an override replaces the combo,
|
||||
* so rebound rows still derive the keys that now fire.
|
||||
* `parsedChord` is the single source of truth for "has no keys": it is derived
|
||||
* from `combo`, so it covers both a user-unassigned shortcut and a binding
|
||||
* authored without a default.
|
||||
*/
|
||||
function displayChordForBinding(binding: ParsedShortcutBinding): ShortcutKey[][] | null {
|
||||
if (binding.parsedChord.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const displayKeys = binding.help?.defaultDisplayKeys;
|
||||
if (displayKeys) {
|
||||
return [displayKeys];
|
||||
}
|
||||
return chordStringToShortcutKeys(binding.combo);
|
||||
}
|
||||
|
||||
export function getDefaultKeysForAction(
|
||||
actionId: string,
|
||||
platform: { isMac: boolean; isDesktop: boolean },
|
||||
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
|
||||
): ShortcutKey[] | null {
|
||||
): ShortcutKey[][] | null {
|
||||
for (const binding of bindings) {
|
||||
if (binding.help?.id !== actionId) {
|
||||
continue;
|
||||
@@ -1434,13 +1409,7 @@ export function getDefaultKeysForAction(
|
||||
if (!helpMatchesPlatform(binding.when, platform)) {
|
||||
continue;
|
||||
}
|
||||
// `help.keys` is hand-authored, so it cannot be trusted to be empty for a
|
||||
// binding that ships without a default combo. The parsed chord is derived
|
||||
// from `combo`, so it is the single source of truth for "has no keys".
|
||||
if (binding.parsedChord.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return binding.help.keys;
|
||||
return displayChordForBinding(binding);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1464,8 +1433,7 @@ export function resolveShortcutKeysForAction(
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
||||
const defaultChord = defaultKeys ? [defaultKeys] : null;
|
||||
const defaultChord = getDefaultKeysForAction(actionId, platform);
|
||||
|
||||
const override = overrides[bindingId];
|
||||
if (override === UNASSIGNED_COMBO || override === "") {
|
||||
@@ -1566,8 +1534,7 @@ export function buildKeyboardShortcutHelpSections(
|
||||
id: help.id,
|
||||
label: help.label,
|
||||
labelKey: SHORTCUT_HELP_LABEL_KEYS[help.id] ?? help.label,
|
||||
// An empty chord has no keys to show, whatever `help.keys` was authored as.
|
||||
keys: binding.parsedChord.length === 0 ? [] : help.keys,
|
||||
chord: displayChordForBinding(binding),
|
||||
...(help.note ? { note: help.note } : {}),
|
||||
...(SHORTCUT_HELP_NOTE_KEYS[help.id] ? { noteKey: SHORTCUT_HELP_NOTE_KEYS[help.id] } : {}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildEffectiveBindings,
|
||||
buildKeyboardShortcutHelpSections,
|
||||
UNASSIGNED_COMBO,
|
||||
type ShortcutOverrides,
|
||||
} from "./keyboard-shortcuts";
|
||||
import { filterShortcutHelpSections, shortcutSearchText } from "./shortcut-help-search";
|
||||
|
||||
const MAC_DESKTOP = { isMac: true, isDesktop: true };
|
||||
const NEW_WORKSPACE_BINDING = "workspace-new-cmd-n-mac";
|
||||
|
||||
/**
|
||||
* The i18n port, filled with the real English strings the rows use. Translation
|
||||
* is the only dependency the filter cannot compute, so it is injected rather
|
||||
* than mocked.
|
||||
*/
|
||||
const EN: Record<string, string> = {
|
||||
"settings.shortcuts.sections.projects": "Projects",
|
||||
"settings.shortcuts.help.newWorkspace": "New workspace",
|
||||
"settings.shortcuts.help.openProject": "Open project",
|
||||
};
|
||||
|
||||
function translate(key: string): string {
|
||||
return EN[key] ?? key;
|
||||
}
|
||||
|
||||
function filter(query: string, overrides: ShortcutOverrides = {}) {
|
||||
return filterShortcutHelpSections({
|
||||
sections: buildKeyboardShortcutHelpSections(MAC_DESKTOP, buildEffectiveBindings(overrides)),
|
||||
query,
|
||||
translate,
|
||||
shortcutOs: "mac",
|
||||
});
|
||||
}
|
||||
|
||||
function matchedRowIds(query: string, overrides: ShortcutOverrides = {}): string[] {
|
||||
return filter(query, overrides).flatMap((section) => section.rows.map((row) => row.id));
|
||||
}
|
||||
|
||||
describe("shortcutSearchText", () => {
|
||||
it("covers the tokens, the rendered badge, and the modifier aliases", () => {
|
||||
const text = shortcutSearchText([["mod", "N"]], "mac");
|
||||
|
||||
expect(text).toContain("mod N");
|
||||
expect(text).toContain("⌘N");
|
||||
expect(text).toContain("cmd+N");
|
||||
expect(text).toContain("command+N");
|
||||
});
|
||||
|
||||
// The alias expansion is combinatorial, so a flattened chord would multiply
|
||||
// the two combos together and invent spellings for keys never pressed at once.
|
||||
it("keeps a multi-step chord's combos apart", () => {
|
||||
const text = shortcutSearchText(
|
||||
[
|
||||
["mod", "K"],
|
||||
["mod", "N"],
|
||||
],
|
||||
"mac",
|
||||
);
|
||||
|
||||
expect(text).toContain("cmd+K");
|
||||
expect(text).toContain("cmd+N");
|
||||
expect(text).not.toContain("cmd+K+cmd+N");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterShortcutHelpSections", () => {
|
||||
it("returns every section for a blank query", () => {
|
||||
expect(filter(" ")).toHaveLength(buildKeyboardShortcutHelpSections(MAC_DESKTOP).length);
|
||||
});
|
||||
|
||||
it("matches a row by its label", () => {
|
||||
expect(matchedRowIds("new workspace")).toContain("new-workspace");
|
||||
});
|
||||
|
||||
it("matches a row by the rendered badge", () => {
|
||||
expect(matchedRowIds("⌘n")).toContain("new-workspace");
|
||||
});
|
||||
|
||||
it("matches a row by a spelled-out modifier", () => {
|
||||
expect(matchedRowIds("command+n")).toContain("new-workspace");
|
||||
});
|
||||
|
||||
// The reported bug, from the search side: the cheat sheet has to stop finding
|
||||
// a row by keys the user has rebound away from.
|
||||
it("finds a rebound row by its new keys and not by the superseded default", () => {
|
||||
const overrides = { [NEW_WORKSPACE_BINDING]: "Cmd+Shift+K" };
|
||||
|
||||
expect(matchedRowIds("cmd+shift+k", overrides)).toContain("new-workspace");
|
||||
expect(matchedRowIds("cmd+n", overrides)).not.toContain("new-workspace");
|
||||
});
|
||||
|
||||
it("still finds an unassigned row by its label", () => {
|
||||
const overrides = { [NEW_WORKSPACE_BINDING]: UNASSIGNED_COMBO };
|
||||
|
||||
expect(matchedRowIds("new workspace", overrides)).toContain("new-workspace");
|
||||
expect(matchedRowIds("cmd+n", overrides)).not.toContain("new-workspace");
|
||||
});
|
||||
|
||||
it("keeps every row of a section whose own title matches", () => {
|
||||
const projects = filter("projects").find((section) => section.id === "projects");
|
||||
const allProjects = buildKeyboardShortcutHelpSections(MAC_DESKTOP).find(
|
||||
(section) => section.id === "projects",
|
||||
);
|
||||
|
||||
expect(projects?.rows).toHaveLength(allProjects?.rows.length ?? 0);
|
||||
});
|
||||
|
||||
it("drops a section whose rows all miss", () => {
|
||||
expect(filter("no shortcut spells this")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { KeyboardShortcutHelpSection } from "@/keyboard/keyboard-shortcuts";
|
||||
import { formatShortcut, type ShortcutKey, type ShortcutOs } from "@/utils/format-shortcut";
|
||||
|
||||
/** Resolves an i18n key to display text. The caller's `t`, injected. */
|
||||
export type TranslateHelpKey = (key: string) => string;
|
||||
|
||||
/**
|
||||
* Alternative spellings for one combo, so searching "cmd k" and "command+k" both
|
||||
* find ⌘K. The modifier a user types rarely matches the token the binding
|
||||
* stores, and never matches the symbol the badge renders.
|
||||
*/
|
||||
export function shortcutSearchAliases(
|
||||
keys: readonly ShortcutKey[],
|
||||
shortcutOs: ShortcutOs,
|
||||
): string {
|
||||
const aliases = keys.map((key) => {
|
||||
if (shortcutOs === "mac") {
|
||||
if (key === "mod" || key === "meta") return ["cmd", "command"];
|
||||
if (key === "alt") return ["alt", "option"];
|
||||
} else {
|
||||
if (key === "mod" || key === "ctrl") return ["ctrl", "control"];
|
||||
if (key === "meta") return ["win", "windows"];
|
||||
}
|
||||
return [key];
|
||||
});
|
||||
const combinations = aliases.reduce<string[][]>(
|
||||
(prefixes, choices) =>
|
||||
prefixes.flatMap((prefix) => choices.map((choice) => [...prefix, choice])),
|
||||
[[]],
|
||||
);
|
||||
return combinations
|
||||
.flatMap((combination) => [combination.join(" "), combination.join("+")])
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Search text for one row's keys. Built per combo and joined, never over a
|
||||
* flattened chord: the alias expansion is combinatorial, so flattening a
|
||||
* multi-step chord into one key list both explodes the combination count and
|
||||
* invents aliases for combos the user never has to press together.
|
||||
*/
|
||||
export function shortcutSearchText(chord: ShortcutKey[][], shortcutOs: ShortcutOs): string {
|
||||
return chord
|
||||
.flatMap((combo) => [
|
||||
combo.join(" "),
|
||||
formatShortcut(combo, shortcutOs),
|
||||
shortcutSearchAliases(combo, shortcutOs),
|
||||
])
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheat sheet's sections narrowed to a query, matching on the row's label,
|
||||
* its note, and every spelling of the keys that actually fire it. A section
|
||||
* whose own title matches keeps all of its rows.
|
||||
*/
|
||||
export function filterShortcutHelpSections({
|
||||
sections,
|
||||
query,
|
||||
translate,
|
||||
shortcutOs,
|
||||
}: {
|
||||
sections: readonly KeyboardShortcutHelpSection[];
|
||||
query: string;
|
||||
translate: TranslateHelpKey;
|
||||
shortcutOs: ShortcutOs;
|
||||
}): KeyboardShortcutHelpSection[] {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
if (!normalizedQuery) return [...sections];
|
||||
|
||||
return sections.flatMap((section) => {
|
||||
if (translate(section.titleKey).toLocaleLowerCase().includes(normalizedQuery)) {
|
||||
return [section];
|
||||
}
|
||||
|
||||
const rows = section.rows.filter((row) => {
|
||||
const searchText = [
|
||||
translate(row.labelKey),
|
||||
row.noteKey ? translate(row.noteKey) : row.note,
|
||||
row.chord ? shortcutSearchText(row.chord, shortcutOs) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLocaleLowerCase();
|
||||
return searchText.includes(normalizedQuery);
|
||||
});
|
||||
|
||||
return rows.length > 0 ? [{ ...section, rows }] : [];
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatShortcut } from "@/utils/format-shortcut";
|
||||
import {
|
||||
comboStringToShortcutKeys,
|
||||
keyComboToString,
|
||||
keyboardEventToComboString,
|
||||
parseShortcutString,
|
||||
SHORTCUT_KEY_NAMES,
|
||||
} from "./shortcut-string";
|
||||
|
||||
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
|
||||
@@ -68,3 +71,40 @@ describe("parseShortcutString round-trips punctuation keys", () => {
|
||||
expect(keyComboToString(combo)).toBe("Cmd+-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("comboStringToShortcutKeys", () => {
|
||||
it("maps every modifier spelling onto its display token", () => {
|
||||
expect(comboStringToShortcutKeys("Cmd+Ctrl+Alt+Shift+K")).toEqual([
|
||||
"mod",
|
||||
"ctrl",
|
||||
"alt",
|
||||
"shift",
|
||||
"K",
|
||||
]);
|
||||
// `parseShortcutString` accepts `Mod` as well, so the display path has to.
|
||||
expect(comboStringToShortcutKeys("Mod+K")).toEqual(["mod", "K"]);
|
||||
});
|
||||
|
||||
it("hands named keys to the display table under the names it uses", () => {
|
||||
expect(comboStringToShortcutKeys("Cmd+Shift+ArrowLeft")).toEqual(["mod", "shift", "Left"]);
|
||||
expect(formatShortcut(comboStringToShortcutKeys("Cmd+Shift+ArrowLeft"), "mac")).toBe(
|
||||
"Shift+⌘+←",
|
||||
);
|
||||
expect(formatShortcut(comboStringToShortcutKeys("Escape"), "mac")).toBe("Esc");
|
||||
expect(formatShortcut(comboStringToShortcutKeys("Backspace"), "mac")).toBe("⌫");
|
||||
});
|
||||
|
||||
// The whole class, not just the arrows: uppercasing key names used to push
|
||||
// every multi-character name past `KEY_DISPLAY`, so an override on one of them
|
||||
// rendered as a shouty raw code — `ARROWLEFT`, `ESCAPE`, `PAGEDOWN`.
|
||||
it("never emits a shouty raw code for any key the capture path can produce", () => {
|
||||
for (const name of SHORTCUT_KEY_NAMES) {
|
||||
// `F1` and the digits are already all-caps, so only names that contain a
|
||||
// lowercase letter can exhibit the bug.
|
||||
if (name === name.toUpperCase()) continue;
|
||||
const [key] = comboStringToShortcutKeys(name);
|
||||
const rendered = formatShortcut([key], "non-mac");
|
||||
expect(rendered, `${name} renders as ${rendered}`).not.toBe(name.toUpperCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,13 @@ for (let i = 1; i <= 12; i++) {
|
||||
KEY_MAP[`F${i}`] = { code: `F${i}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every key name a combo string can carry. Exported so the display path can be
|
||||
* driven over the whole vocabulary instead of a hand-copied sample that goes
|
||||
* stale the moment `KEY_MAP` gains an entry.
|
||||
*/
|
||||
export const SHORTCUT_KEY_NAMES: readonly string[] = Object.keys(KEY_MAP);
|
||||
|
||||
const CODE_TO_KEY: Record<string, string> = {};
|
||||
for (const [humanKey, mapping] of Object.entries(KEY_MAP)) {
|
||||
if (!CODE_TO_KEY[mapping.code]) {
|
||||
@@ -177,6 +184,21 @@ const MODIFIER_CODES = new Set([
|
||||
"ShiftRight",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Key names as combo strings spell them, mapped to the names `KEY_DISPLAY` in
|
||||
* `format-shortcut` is keyed on. The two vocabularies were never reconciled:
|
||||
* `keyboardEventToComboString` emits `ArrowLeft` and `Escape` (from `KEY_MAP`),
|
||||
* while the display table only knows `Left` and `Esc`. Without this bridge an
|
||||
* override on a named key renders as a raw code — `ARROWLEFT`, not `←`.
|
||||
*/
|
||||
const DISPLAY_KEY_ALIASES: Record<string, string> = {
|
||||
ArrowLeft: "Left",
|
||||
ArrowRight: "Right",
|
||||
ArrowUp: "Up",
|
||||
ArrowDown: "Down",
|
||||
Escape: "Esc",
|
||||
};
|
||||
|
||||
export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
|
||||
const parts = comboString.split("+");
|
||||
const keys: ShortcutKey[] = [];
|
||||
@@ -185,6 +207,11 @@ export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
|
||||
case "Cmd":
|
||||
keys.push("mod");
|
||||
break;
|
||||
// `parseShortcutString` accepts `Mod`, so the display path has to as well
|
||||
// or a combo it can match would render the literal word.
|
||||
case "Mod":
|
||||
keys.push("mod");
|
||||
break;
|
||||
case "Ctrl":
|
||||
keys.push("ctrl");
|
||||
break;
|
||||
@@ -195,7 +222,10 @@ export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
|
||||
keys.push("shift");
|
||||
break;
|
||||
default:
|
||||
keys.push(part.toUpperCase());
|
||||
// Pass the key through unchanged. `normalizeKey` already uppercases
|
||||
// single characters, and uppercasing here is what used to push
|
||||
// multi-character names past `KEY_DISPLAY` into raw output.
|
||||
keys.push(DISPLAY_KEY_ALIASES[part] ?? part);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3042,7 +3042,7 @@ describe("HostRuntimeStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("upsertDirectConnection stores SSL, password, and custom header settings", async () => {
|
||||
it("upsertDirectConnection stores SSL and password settings", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
@@ -3060,7 +3060,6 @@ describe("HostRuntimeStore", () => {
|
||||
endpoint: "example.paseo.test:7443",
|
||||
useTls: true,
|
||||
password: "shared-secret",
|
||||
headers: { "X-Tenant": "acme" },
|
||||
label: "tls host",
|
||||
});
|
||||
|
||||
@@ -3072,7 +3071,6 @@ describe("HostRuntimeStore", () => {
|
||||
endpoint: "example.paseo.test:7443",
|
||||
useTls: true,
|
||||
password: "shared-secret",
|
||||
headers: { "X-Tenant": "acme" },
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
createDesktopWebSocketTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
@@ -479,10 +478,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
return {
|
||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
const webSocketTransportFactory = createDesktopWebSocketTransportFactory();
|
||||
const webSocketConfig = webSocketTransportFactory
|
||||
? { transportFactory: webSocketTransportFactory }
|
||||
: { webSocketFactory: createAppWebSocketFactory() };
|
||||
const webSocketConfig = { webSocketFactory: createAppWebSocketFactory() };
|
||||
const base = {
|
||||
suppressSendErrors: true,
|
||||
clientId,
|
||||
@@ -510,7 +506,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
useTls: connection.useTls ?? false,
|
||||
}),
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
...(connection.headers ? { headers: connection.headers } : {}),
|
||||
});
|
||||
}
|
||||
return new DaemonClient({
|
||||
@@ -1651,7 +1646,6 @@ export class HostRuntimeStore {
|
||||
endpoint: string;
|
||||
useTls?: boolean;
|
||||
password?: string;
|
||||
headers?: Record<string, string>;
|
||||
label?: string;
|
||||
existingClient?: DaemonClient;
|
||||
}): Promise<HostProfile> {
|
||||
@@ -1666,7 +1660,6 @@ export class HostRuntimeStore {
|
||||
endpoint,
|
||||
useTls: input.useTls ?? false,
|
||||
...(password ? { password } : {}),
|
||||
...(input.headers ? { headers: input.headers } : {}),
|
||||
},
|
||||
existingClient: input.existingClient,
|
||||
});
|
||||
@@ -1708,7 +1701,6 @@ export class HostRuntimeStore {
|
||||
endpoint: string;
|
||||
useTls?: boolean;
|
||||
password?: string;
|
||||
headers?: Record<string, string>;
|
||||
label?: string;
|
||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
const endpoint = normalizeHostPort(input.endpoint);
|
||||
@@ -1721,7 +1713,6 @@ export class HostRuntimeStore {
|
||||
endpoint,
|
||||
useTls: input.useTls ?? false,
|
||||
...(password ? { password } : {}),
|
||||
...(input.headers ? { headers: input.headers } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2463,14 +2454,12 @@ export interface HostMutations {
|
||||
endpoint: string;
|
||||
useTls?: boolean;
|
||||
password?: string;
|
||||
headers?: Record<string, string>;
|
||||
label?: string;
|
||||
}) => Promise<HostProfile>;
|
||||
probeAndUpsertDirectConnection: (input: {
|
||||
endpoint: string;
|
||||
useTls?: boolean;
|
||||
password?: string;
|
||||
headers?: Record<string, string>;
|
||||
label?: string;
|
||||
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
upsertRelayConnection: (input: {
|
||||
|
||||
@@ -508,7 +508,7 @@ function TabHandleContent({
|
||||
return (
|
||||
<View style={styles.tabHandle} dataSet={tabHandleDataSet}>
|
||||
<View style={styles.tabIcon}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} />
|
||||
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} backdrop="surface0" />
|
||||
</View>
|
||||
{showLabel && presentation.titleState === "loading" ? (
|
||||
<View style={tabLabelSkeletonStyle} />
|
||||
|
||||
@@ -175,6 +175,7 @@ import {
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
@@ -429,10 +430,12 @@ function MobileActiveTabTrigger({
|
||||
activeTab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
backdrop,
|
||||
}: {
|
||||
activeTab: WorkspaceTabDescriptor | null;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
if (!activeTab) {
|
||||
return null;
|
||||
@@ -443,6 +446,7 @@ function MobileActiveTabTrigger({
|
||||
activeTab={activeTab}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
backdrop={backdrop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -451,10 +455,12 @@ function ResolvedMobileActiveTabTrigger({
|
||||
activeTab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
backdrop,
|
||||
}: {
|
||||
activeTab: WorkspaceTabDescriptor;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -466,7 +472,7 @@ function ResolvedMobileActiveTabTrigger({
|
||||
{(presentation) => (
|
||||
<>
|
||||
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
|
||||
<WorkspaceTabIcon presentation={presentation} active />
|
||||
<WorkspaceTabIcon presentation={presentation} active backdrop={backdrop} />
|
||||
</View>
|
||||
|
||||
<Text style={styles.switcherTriggerText} numberOfLines={1}>
|
||||
@@ -741,14 +747,19 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
style={switcherTriggerStyle}
|
||||
onPress={handleOpenSwitcher}
|
||||
>
|
||||
<View style={styles.switcherTriggerLeft}>
|
||||
<MobileActiveTabTrigger
|
||||
activeTab={activeTab}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
</View>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
{({ pressed }) => (
|
||||
<>
|
||||
<View style={styles.switcherTriggerLeft}>
|
||||
<MobileActiveTabTrigger
|
||||
activeTab={activeTab}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
backdrop={pressed ? "surface1" : "surface0"}
|
||||
/>
|
||||
</View>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Combobox
|
||||
|
||||
@@ -8,7 +8,10 @@ import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { getStatusRingOffset } from "@/components/status-ring/geometry";
|
||||
import {
|
||||
STATUS_INDICATOR_ALERT_SIZE,
|
||||
STATUS_INDICATOR_DOT_SIZE,
|
||||
@@ -112,6 +115,13 @@ interface WorkspaceTabIconProps {
|
||||
active?: boolean;
|
||||
size?: number;
|
||||
statusDotBorderColor?: string;
|
||||
/**
|
||||
* The surface this icon is sitting on, so the running ring can knock out of it. This icon is
|
||||
* shared by the desktop tab strip, the tab switcher trigger, the split drag chip and the
|
||||
* subagents track, which are on different surfaces and some of which change surface on hover —
|
||||
* it cannot work the colour out for itself.
|
||||
*/
|
||||
backdrop: SurfaceBackdrop;
|
||||
}
|
||||
|
||||
const ThemedCheckIcon = withUnistyles(Check);
|
||||
@@ -127,12 +137,13 @@ export function WorkspaceTabIcon({
|
||||
active = false,
|
||||
size = 14,
|
||||
statusDotBorderColor,
|
||||
backdrop,
|
||||
}: WorkspaceTabIconProps): ReactElement {
|
||||
const iconColor = active ? styles.iconActive.color : styles.iconInactive.color;
|
||||
const bucket = presentation.statusBucket;
|
||||
const isRunning = bucket === "running";
|
||||
let statusDotColor: string | undefined;
|
||||
if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
|
||||
else if (bucket === "running") statusDotColor = styles.statusDotRunning.color;
|
||||
else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
|
||||
const showNeedsInputAlert = bucket === "needs_input";
|
||||
const Icon = presentation.icon;
|
||||
@@ -154,13 +165,16 @@ export function WorkspaceTabIcon({
|
||||
return (
|
||||
<View style={agentIconWrapperStyle}>
|
||||
<Icon size={size} color={iconColor} />
|
||||
{statusDotColor ? (
|
||||
{isRunning ? (
|
||||
<View
|
||||
style={statusDotStyle}
|
||||
accessibilityRole={bucket === "running" ? "progressbar" : undefined}
|
||||
accessibilityLabel={bucket === "running" ? "Agent running" : undefined}
|
||||
/>
|
||||
style={styles.statusRing}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel="Agent running"
|
||||
>
|
||||
<StatusRing backdrop={backdrop} />
|
||||
</View>
|
||||
) : null}
|
||||
{statusDotColor ? <View style={statusDotStyle} /> : null}
|
||||
{showNeedsInputAlert ? (
|
||||
<View style={styles.statusAlertOverlay}>
|
||||
<ThemedCircleAlert size={STATUS_INDICATOR_ALERT_SIZE} uniProps={needsInputAlertMapping} />
|
||||
@@ -186,13 +200,18 @@ export function WorkspaceTabOptionRow({
|
||||
trailingAccessory,
|
||||
}: WorkspaceTabOptionRowProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.optionMainPressable,
|
||||
(Boolean(hovered) || pressed || active) && styles.optionRowActive,
|
||||
],
|
||||
const isOptionActive = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
Boolean(hovered) || pressed || active,
|
||||
[active],
|
||||
);
|
||||
const pressableStyle = useCallback(
|
||||
(state: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.optionMainPressable,
|
||||
isOptionActive(state) && styles.optionRowActive,
|
||||
],
|
||||
[isOptionActive],
|
||||
);
|
||||
const optionRowStyle = useMemo(
|
||||
() => [styles.optionRow, active && styles.optionRowActive],
|
||||
[active],
|
||||
@@ -200,16 +219,27 @@ export function WorkspaceTabOptionRow({
|
||||
return (
|
||||
<View style={optionRowStyle}>
|
||||
<Pressable onPress={onPress} style={pressableStyle}>
|
||||
<View style={styles.optionLeadingSlot}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text numberOfLines={1} style={styles.optionLabel}>
|
||||
{presentation.titleState === "loading"
|
||||
? t("workspace.tabs.loading")
|
||||
: presentation.label}
|
||||
</Text>
|
||||
</View>
|
||||
{(state) => {
|
||||
const optionActive = isOptionActive(state);
|
||||
return (
|
||||
<>
|
||||
<View style={styles.optionLeadingSlot}>
|
||||
<WorkspaceTabIcon
|
||||
presentation={presentation}
|
||||
active={selected || active}
|
||||
backdrop={optionActive ? "surface1" : "surface0"}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text numberOfLines={1} style={styles.optionLabel}>
|
||||
{presentation.titleState === "loading"
|
||||
? t("workspace.tabs.loading")
|
||||
: presentation.label}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
{presentation.modified ? (
|
||||
<View style={styles.optionModifiedDot} accessibilityLabel={t("workspace.tabs.modified")} />
|
||||
@@ -241,6 +271,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
statusRing: {
|
||||
position: "absolute",
|
||||
right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
|
||||
bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
|
||||
},
|
||||
statusDotBorderDefault: {
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
|
||||
@@ -77,8 +77,8 @@ describe("createMarkdownStyles", () => {
|
||||
expect(styles.code_inline).toMatchObject({
|
||||
fontFamily: darkTheme.fontFamily.mono,
|
||||
fontSize: darkTheme.fontSize.code,
|
||||
lineHeight: Math.round(darkTheme.fontSize.code * 1.45),
|
||||
});
|
||||
expect(styles.code_inline).not.toHaveProperty("lineHeight");
|
||||
expect(styles.code_block).toMatchObject({
|
||||
fontFamily: darkTheme.fontFamily.mono,
|
||||
fontSize: darkTheme.fontSize.code,
|
||||
@@ -90,7 +90,7 @@ describe("createMarkdownStyles", () => {
|
||||
expect(compactStyles.code_inline).toMatchObject({
|
||||
fontFamily: darkTheme.fontFamily.mono,
|
||||
fontSize: darkTheme.fontSize.code,
|
||||
lineHeight: Math.round(darkTheme.fontSize.code * 1.45),
|
||||
});
|
||||
expect(compactStyles.code_inline).not.toHaveProperty("lineHeight");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,7 +172,6 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
borderWidth: 0,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: Math.round(theme.fontSize.code * 1.45),
|
||||
},
|
||||
|
||||
code_block: {
|
||||
|
||||
@@ -11,4 +11,14 @@
|
||||
* `StyleSheet.create` — see docs/unistyles.md. Each knockout maps the name to a style of its own.
|
||||
* Add a token here when a knockout lands on a surface that isn't listed.
|
||||
*/
|
||||
export type SurfaceBackdrop = "surfaceSidebar" | "surfaceSidebarHover" | "surface2";
|
||||
export type SurfaceBackdrop =
|
||||
| "surface0"
|
||||
| "surface1"
|
||||
| "surface2"
|
||||
| "surfaceSidebar"
|
||||
| "surfaceSidebarHover";
|
||||
|
||||
export type SidebarSurfaceBackdrop = Extract<
|
||||
SurfaceBackdrop,
|
||||
"surfaceSidebar" | "surfaceSidebarHover" | "surface2"
|
||||
>;
|
||||
|
||||
@@ -165,11 +165,18 @@ const darkStatusColors = {
|
||||
// for them. Same four hues and the same generation rule as the status colors above, but its
|
||||
// own band, because a dot is doing a different job than a check icon or a host badge.
|
||||
//
|
||||
// A dot is 6-8pt of solid color with no shape to read and no label attached. At the status
|
||||
// A dot is 6pt of solid color with no shape to read and no label attached. At the status
|
||||
// band's lightness the dots read dimmer than the static text and icons beside them on the same
|
||||
// row, which is backwards — the dot is the row's state. So the band is pushed away from the
|
||||
// surface rather than toward it (darker in light, lighter in dark) and carries more chroma:
|
||||
// 90% of gamut max against the status family's 55-60%.
|
||||
// row, which is backwards — the dot is the row's state. The loudness comes from chroma: 90% of
|
||||
// gamut max against the status family's 55-60%.
|
||||
//
|
||||
// Lightness is set by hue separation, not by distance from the surface. A dark dot on a light
|
||||
// surface has plenty of contrast but the four hues collapse into each other at 6pt — dark green,
|
||||
// dark red, dark amber and dark blue all read as "dark blob", and the point of the dot is telling
|
||||
// them apart at a glance. So the light band runs as bright as the contrast floor allows: L=0.62
|
||||
// is the last step where all four clear 3:1 against the sidebar's surface2 (success is the
|
||||
// binding one at 3.10, and drops under 3 by L=0.64), which is WCAG's non-text minimum for a
|
||||
// control that carries state.
|
||||
//
|
||||
// All four move together. A dot matching its siblings in lightness and chroma says only which
|
||||
// state the row is in; one that does not says "this row matters more", which is a claim the
|
||||
@@ -178,14 +185,16 @@ const darkStatusColors = {
|
||||
// 90% and not 100%: at the gamut edge the lopsidedness is worst — green reaches C=0.215 while
|
||||
// blue manages 0.116 — so the set stops reading as one family and green wins. Red running out
|
||||
// of chroma as lightness climbs is what caps the dark band at L=0.72; higher turns the failed
|
||||
// dot pink. Running is blue at hue 250, clear of identity-colors' blue at 256.6 so a blue host
|
||||
// badge and a working dot on the same row do not read as related.
|
||||
// dot pink, and the light band pastels out the same way just above its own cap. Running is blue
|
||||
// at hue 250, clear of
|
||||
// identity-colors' blue at 256.6 so a blue host badge and a working dot on the same row do not
|
||||
// read as related.
|
||||
const lightStatusDotColors = {
|
||||
// L=0.46, chroma 90% of gamut max
|
||||
statusDotSuccess: "#186933",
|
||||
statusDotDanger: "#a11c1c",
|
||||
statusDotWarning: "#774e14",
|
||||
statusDotRunning: "#165a96",
|
||||
// L=0.62, chroma 90% of gamut max
|
||||
statusDotSuccess: "#299f51",
|
||||
statusDotDanger: "#f12e2f",
|
||||
statusDotWarning: "#b37824",
|
||||
statusDotRunning: "#268ae0",
|
||||
};
|
||||
|
||||
const darkStatusDotColors = {
|
||||
|
||||
@@ -200,7 +200,10 @@ function SubagentsTrackRow({
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<View style={hovered || pressed ? styles.rowActive : styles.row}>
|
||||
<WorkspaceTabIcon presentation={presentation} />
|
||||
<WorkspaceTabIcon
|
||||
presentation={presentation}
|
||||
backdrop={hovered || pressed ? "surface2" : "surface1"}
|
||||
/>
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{displayLabel}
|
||||
</Text>
|
||||
|
||||
@@ -75,49 +75,6 @@ describe("normalizeStoredHostProfile", () => {
|
||||
expect(profile?.connections[0]).not.toHaveProperty("password");
|
||||
});
|
||||
|
||||
it("preserves custom headers on stored direct TCP connections", () => {
|
||||
const profile = normalizeStoredHostProfile({
|
||||
serverId: "srv_headers",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
headers: { "X-Tenant": "acme" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(profile?.connections[0]).toEqual({
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
useTls: false,
|
||||
headers: { "X-Tenant": "acme" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the connection but discards malformed stored custom headers", () => {
|
||||
const profile = normalizeStoredHostProfile({
|
||||
serverId: "srv_malformed_headers",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
headers: { "X-Tenant": 42 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(profile?.connections[0]).toEqual({
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
useTls: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legacy relay ids when TLS is absent", () => {
|
||||
const profile = normalizeStoredHostProfile({
|
||||
serverId: "srv_relay",
|
||||
@@ -219,59 +176,33 @@ describe("upsertHostConnectionInProfiles", () => {
|
||||
expect(profile.appearance).toEqual({ color: "amber", badgeDisplay: "hidden" });
|
||||
});
|
||||
|
||||
it("deduplicates matching custom headers regardless of key order", () => {
|
||||
const firstConnection: HostConnection = {
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
headers: { "X-One": "1", "X-Two": "2" },
|
||||
};
|
||||
const existing = {
|
||||
...makeHost("srv_known"),
|
||||
connections: [firstConnection],
|
||||
};
|
||||
|
||||
const [profile] = upsertHostConnectionInProfiles({
|
||||
profiles: [existing],
|
||||
serverId: "srv_known",
|
||||
connection: {
|
||||
...firstConnection,
|
||||
headers: { "X-Two": "2", "X-One": "1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile.connections).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("replaces a direct connection when its custom headers change", () => {
|
||||
it("replaces a direct connection when its settings change", () => {
|
||||
const existingConnection: HostConnection = {
|
||||
id: "direct:example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "example.test:6767",
|
||||
headers: { "X-Tenant": "old" },
|
||||
useTls: false,
|
||||
password: "old-secret",
|
||||
};
|
||||
const existing = {
|
||||
const existing: HostProfile = {
|
||||
...makeHost("srv_known"),
|
||||
connections: [existingConnection],
|
||||
preferredConnectionId: existingConnection.id,
|
||||
};
|
||||
const replacement: HostConnection = {
|
||||
...existingConnection,
|
||||
useTls: true,
|
||||
password: "new-secret",
|
||||
};
|
||||
|
||||
const [profile] = upsertHostConnectionInProfiles({
|
||||
profiles: [existing],
|
||||
serverId: "srv_known",
|
||||
connection: {
|
||||
...existingConnection,
|
||||
headers: { "X-Tenant": "new" },
|
||||
},
|
||||
connection: replacement,
|
||||
});
|
||||
|
||||
expect(profile.connections).toEqual([
|
||||
{
|
||||
...existingConnection,
|
||||
headers: { "X-Tenant": "new" },
|
||||
},
|
||||
]);
|
||||
expect(profile.preferredConnectionId).toBe(existingConnection.id);
|
||||
expect(profile.connections).toEqual([replacement]);
|
||||
expect(profile.preferredConnectionId).toBe(replacement.id);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
defaultHostAppearance,
|
||||
normalizeStoredHostAppearance,
|
||||
} from "@/hosts/appearance";
|
||||
import { normalizeConnectionHeadersRecord } from "@/utils/connection-headers";
|
||||
|
||||
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
|
||||
|
||||
@@ -105,18 +104,6 @@ export function resolveActiveHostServerId(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function stringRecordEquals(
|
||||
left: Record<string, string> | undefined,
|
||||
right: Record<string, string> | undefined,
|
||||
): boolean {
|
||||
const leftEntries = Object.entries(left ?? {});
|
||||
const rightRecord = right ?? {};
|
||||
return (
|
||||
leftEntries.length === Object.keys(rightRecord).length &&
|
||||
leftEntries.every(([key, value]) => rightRecord[key] === value)
|
||||
);
|
||||
}
|
||||
|
||||
function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean {
|
||||
if (left.type !== right.type || left.id !== right.id) {
|
||||
return false;
|
||||
@@ -126,8 +113,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
|
||||
return (
|
||||
left.endpoint === right.endpoint &&
|
||||
(left.useTls ?? false) === (right.useTls ?? false) &&
|
||||
left.password === right.password &&
|
||||
stringRecordEquals(left.headers, right.headers)
|
||||
left.password === right.password
|
||||
);
|
||||
}
|
||||
if (left.type === "directSocket" && right.type === "directSocket") {
|
||||
@@ -315,11 +301,6 @@ function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isPlainRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeStoredHeaders(value: unknown): { headers?: Record<string, string> } {
|
||||
const headers = normalizeConnectionHeadersRecord(value);
|
||||
return headers ? { headers } : {};
|
||||
}
|
||||
|
||||
function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
||||
const record = toObjectRecord(connection);
|
||||
if (!record) {
|
||||
@@ -337,7 +318,6 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
||||
endpoint,
|
||||
useTls: record.useTls,
|
||||
...(typeof record.password === "string" ? { password: record.password } : {}),
|
||||
...normalizeStoredHeaders(record.headers),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeConnectionHeadersRecord, prepareConnectionHeaders } from "./connection-headers";
|
||||
|
||||
describe("prepareConnectionHeaders", () => {
|
||||
it("trims and returns custom headers", () => {
|
||||
expect(
|
||||
prepareConnectionHeaders([
|
||||
{ id: 1, name: " X-Tenant ", value: " acme " },
|
||||
{ id: 2, name: "X-Empty", value: "" },
|
||||
]),
|
||||
).toEqual({ headers: { "X-Tenant": "acme", "X-Empty": "" } });
|
||||
});
|
||||
|
||||
it("ignores completely empty draft rows", () => {
|
||||
expect(prepareConnectionHeaders([{ id: 1, name: "", value: "" }])).toEqual({});
|
||||
});
|
||||
|
||||
it("requires a name when a value is entered", () => {
|
||||
expect(prepareConnectionHeaders([{ id: 1, name: "", value: "acme" }])).toEqual({
|
||||
issue: { type: "missingName" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid and duplicate names case-insensitively", () => {
|
||||
expect(prepareConnectionHeaders([{ id: 1, name: "Bad Header", value: "value" }])).toEqual({
|
||||
issue: { type: "invalidName", name: "Bad Header" },
|
||||
});
|
||||
expect(
|
||||
prepareConnectionHeaders([
|
||||
{ id: 1, name: "X-Tenant", value: "one" },
|
||||
{ id: 2, name: "x-tenant", value: "two" },
|
||||
]),
|
||||
).toEqual({ issue: { type: "duplicateName", name: "x-tenant" } });
|
||||
});
|
||||
|
||||
it("rejects line breaks in values", () => {
|
||||
expect(prepareConnectionHeaders([{ id: 1, name: "X-Test", value: "one\ntwo" }])).toEqual({
|
||||
issue: { type: "invalidValue", name: "X-Test" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeConnectionHeadersRecord", () => {
|
||||
it("accepts a valid persisted string record", () => {
|
||||
expect(normalizeConnectionHeadersRecord({ "X-Tenant": "acme" })).toEqual({
|
||||
"X-Tenant": "acme",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed persisted header values", () => {
|
||||
expect(normalizeConnectionHeadersRecord({ "X-Tenant": 42 })).toBeUndefined();
|
||||
expect(
|
||||
normalizeConnectionHeadersRecord({ "X-Tenant": "acme\r\nX-Injected: true" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
export interface ConnectionHeaderDraft {
|
||||
id: number;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type ConnectionHeaderValidationIssue =
|
||||
| { type: "missingName" }
|
||||
| { type: "invalidName"; name: string }
|
||||
| { type: "invalidValue"; name: string }
|
||||
| { type: "duplicateName"; name: string };
|
||||
|
||||
const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function prepareConnectionHeaders(drafts: readonly ConnectionHeaderDraft[]): {
|
||||
headers?: Record<string, string>;
|
||||
issue?: ConnectionHeaderValidationIssue;
|
||||
} {
|
||||
const headers: Record<string, string> = {};
|
||||
const normalizedNames = new Set<string>();
|
||||
|
||||
for (const draft of drafts) {
|
||||
const name = draft.name.trim();
|
||||
const value = draft.value.trim();
|
||||
if (!name && !value) continue;
|
||||
if (!name) return { issue: { type: "missingName" } };
|
||||
if (!HTTP_HEADER_NAME_PATTERN.test(name)) {
|
||||
return { issue: { type: "invalidName", name } };
|
||||
}
|
||||
if (/\r|\n/.test(value)) {
|
||||
return { issue: { type: "invalidValue", name } };
|
||||
}
|
||||
|
||||
const normalizedName = name.toLowerCase();
|
||||
if (normalizedNames.has(normalizedName)) {
|
||||
return { issue: { type: "duplicateName", name } };
|
||||
}
|
||||
normalizedNames.add(normalizedName);
|
||||
headers[name] = value;
|
||||
}
|
||||
|
||||
return Object.keys(headers).length > 0 ? { headers } : {};
|
||||
}
|
||||
|
||||
export function normalizeConnectionHeadersRecord(
|
||||
value: unknown,
|
||||
): Record<string, string> | undefined {
|
||||
if (!isPlainRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const drafts: ConnectionHeaderDraft[] = [];
|
||||
for (const [name, headerValue] of Object.entries(value)) {
|
||||
if (typeof headerValue !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
drafts.push({ id: drafts.length, name, value: headerValue });
|
||||
}
|
||||
const result = prepareConnectionHeaders(drafts);
|
||||
return result.issue ? undefined : result.headers;
|
||||
}
|
||||
@@ -32,9 +32,3 @@ export function getStatusDotColor(input: {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isEmphasizedStatusDotBucket(
|
||||
bucket: SidebarStateBucket | null | undefined,
|
||||
): boolean {
|
||||
return bucket === "needs_input" || bucket === "attention";
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const STATUS_INDICATOR_DOT_SIZE = 8;
|
||||
export const STATUS_INDICATOR_FILLED_DOT_SIZE = 6;
|
||||
export const STATUS_INDICATOR_DOT_SIZE = STATUS_INDICATOR_FILLED_DOT_SIZE + 2;
|
||||
export const STATUS_INDICATOR_ALERT_SIZE = 12;
|
||||
|
||||
@@ -101,6 +101,29 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
expect(probe.clientIdsRequested).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps direct TCP probes on the renderer WebSocket", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const deps = {
|
||||
...probe.deps,
|
||||
createWebSocketTransportFactory: () => {
|
||||
throw new Error("Direct TCP must not use the desktop WebSocket bridge");
|
||||
},
|
||||
};
|
||||
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
undefined,
|
||||
deps,
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("encodes the local socket target into the client config", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
@@ -134,46 +157,6 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
|
||||
});
|
||||
|
||||
it("passes direct TCP custom headers into the initial probe client config", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
headers: { "X-Tenant": "acme" },
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(probe.createdConfigs()[0]?.headers).toEqual({ "X-Tenant": "acme" });
|
||||
});
|
||||
|
||||
it("uses the Electron WebSocket transport for the initial probe", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const transportFactory: NonNullable<DaemonClientConfig["transportFactory"]> = () => {
|
||||
throw new Error("The fake probe client should not open the transport.");
|
||||
};
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
headers: { "X-Tenant": "acme" },
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
...probe.deps,
|
||||
createWebSocketTransportFactory: () => transportFactory,
|
||||
},
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(probe.createdConfigs()[0]?.transportFactory).toBe(transportFactory);
|
||||
});
|
||||
|
||||
it("passes performance tracing into the connected client", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const trace = {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
createDesktopWebSocketTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
|
||||
export interface DaemonProbeClient {
|
||||
@@ -30,7 +29,6 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
|
||||
getClientId(): Promise<string>;
|
||||
resolveAppVersion(): string | null;
|
||||
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
|
||||
createWebSocketTransportFactory?(): DaemonClientConfig["transportFactory"] | null;
|
||||
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
|
||||
createClient(config: DaemonClientConfig): TClient;
|
||||
}
|
||||
@@ -39,17 +37,10 @@ const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonCl
|
||||
getClientId: getOrCreateClientId,
|
||||
resolveAppVersion,
|
||||
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
|
||||
createWebSocketTransportFactory: createDesktopWebSocketTransportFactory,
|
||||
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
|
||||
createClient: (config) => new DaemonClient(config),
|
||||
};
|
||||
|
||||
function resolveWebSocketTransportFactory(
|
||||
deps: Pick<DaemonConnectionDependencies<DaemonProbeClient>, "createWebSocketTransportFactory">,
|
||||
): DaemonClientConfig["transportFactory"] {
|
||||
return deps.createWebSocketTransportFactory?.() ?? undefined;
|
||||
}
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -112,11 +103,7 @@ export async function buildClientConfig(
|
||||
},
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
| "getClientId"
|
||||
| "resolveAppVersion"
|
||||
| "createLocalTransportFactory"
|
||||
| "createWebSocketTransportFactory"
|
||||
| "buildLocalTransportUrl"
|
||||
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
|
||||
> = defaultDaemonConnectionDependencies,
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = await deps.getClientId();
|
||||
@@ -148,10 +135,8 @@ export async function buildClientConfig(
|
||||
if (connection.type === "directTcp") {
|
||||
return {
|
||||
...base,
|
||||
transportFactory: resolveWebSocketTransportFactory(deps),
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
...(connection.headers ? { headers: connection.headers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.3.0",
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/server": "0.3.0",
|
||||
"@getpaseo/client": "0.3.1",
|
||||
"@getpaseo/protocol": "0.3.1",
|
||||
"@getpaseo/server": "0.3.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/relay": "0.3.0",
|
||||
"@getpaseo/protocol": "0.3.1",
|
||||
"@getpaseo/relay": "0.3.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -707,33 +707,6 @@ test("passes password as HTTP bearer header and WebSocket subprotocol", async ()
|
||||
});
|
||||
});
|
||||
|
||||
test("passes custom headers and keeps password authorization authoritative", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
const transportFactory = vi.fn(() => mock.transport);
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
password: "shared-secret",
|
||||
headers: { "X-Tenant": "acme", Authorization: "Bearer ignored" },
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
expect(transportFactory).toHaveBeenCalledWith({
|
||||
url: "ws://test",
|
||||
headers: { "X-Tenant": "acme", Authorization: "Bearer shared-secret" },
|
||||
protocols: ["paseo.bearer.shared-secret"],
|
||||
});
|
||||
});
|
||||
|
||||
test("advertises client capabilities in hello", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -301,7 +301,6 @@ export interface DaemonClientConfig {
|
||||
runtimeGeneration?: number | null;
|
||||
password?: string;
|
||||
authHeader?: string;
|
||||
headers?: Record<string, string>;
|
||||
suppressSendErrors?: boolean;
|
||||
transportFactory?: DaemonTransportFactory;
|
||||
webSocketFactory?: WebSocketFactory;
|
||||
@@ -1246,7 +1245,7 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { ...this.config.headers };
|
||||
const headers: Record<string, string> = {};
|
||||
const password = normalizePassword(this.config.password);
|
||||
if (password) {
|
||||
headers.Authorization = `Bearer ${password}`;
|
||||
|
||||
@@ -57,7 +57,6 @@ export interface PaseoClientConfig {
|
||||
runtimeGeneration?: number | null;
|
||||
password?: string;
|
||||
authHeader?: string;
|
||||
headers?: Record<string, string>;
|
||||
suppressSendErrors?: boolean;
|
||||
logger?: PaseoLogger;
|
||||
connectTimeoutMs?: number;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { expect, test } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
|
||||
import { getServerId } from "../../app/e2e/support/helpers/server-id";
|
||||
import {
|
||||
openAddHostFlow,
|
||||
selectHostConnectionType,
|
||||
toggleHostAdvanced,
|
||||
} from "../../app/e2e/support/helpers/settings";
|
||||
import { installDesktopRuntime } from "./support/runtime";
|
||||
|
||||
test("Electron users can add and remove direct connection custom headers", async ({ page }) => {
|
||||
await installDesktopRuntime(page, { serverId: getServerId() });
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
await toggleHostAdvanced(page);
|
||||
|
||||
const addHeader = page.getByTestId("direct-header-add");
|
||||
await expect(addHeader).toBeVisible();
|
||||
await addHeader.click();
|
||||
|
||||
await page.getByTestId("direct-header-name-0").fill("X-Tenant");
|
||||
await page.getByTestId("direct-header-value-0").fill("acme");
|
||||
await expect(page.getByTestId("direct-header-name-0")).toHaveValue("X-Tenant");
|
||||
await expect(page.getByTestId("direct-header-value-0")).toHaveValue("acme");
|
||||
|
||||
await page.getByTestId("direct-header-remove-0").click();
|
||||
await expect(page.getByTestId("direct-header-name-0")).toHaveCount(0);
|
||||
await expect(page.getByTestId("direct-header-value-0")).toHaveCount(0);
|
||||
});
|
||||
@@ -51,6 +51,17 @@ async function openRowMenu(page: Page) {
|
||||
await expect(page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`)).toBeVisible();
|
||||
}
|
||||
|
||||
/** Reachable from the sidebar even when the cheat sheet's own shortcut is gone. */
|
||||
async function openCheatSheet(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
|
||||
await page.getByTestId("sidebar-help-shortcuts").click();
|
||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function closeRowMenu(page: Page) {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`)).toHaveCount(0);
|
||||
@@ -120,20 +131,38 @@ test("an unassigned shortcut lists no keys in the shortcuts cheat sheet", async
|
||||
await page.getByTestId(`shortcut-clear-${SHORTCUTS_ROW}`).click();
|
||||
await expect(page.getByText("Not set", { exact: true })).toBeVisible();
|
||||
|
||||
// Reachable from the sidebar even with its own shortcut unassigned.
|
||||
await gotoAppShell(page);
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
|
||||
await page.getByTestId("sidebar-help-shortcuts").click();
|
||||
const dialog = await openCheatSheet(page);
|
||||
|
||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const row = dialog
|
||||
.locator("div")
|
||||
.filter({ hasText: /^Show keyboard shortcuts$/ })
|
||||
.first();
|
||||
// Addressed by testID rather than by filtering on its own text: an anchored
|
||||
// `hasText` matches only the innermost node holding exactly that label, so a
|
||||
// row-scoped assertion about anything *else* in the row finds nothing.
|
||||
const row = dialog.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`);
|
||||
await expect(row).toBeVisible();
|
||||
// No badge pill, blank or otherwise, for a shortcut with no keys.
|
||||
await expect(dialog.getByText("?", { exact: true })).toHaveCount(0);
|
||||
// It says so, rather than leaving a silent gap where the keys were. Same words
|
||||
// the settings row uses for the same state.
|
||||
await expect(row.getByText("Not set", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a rebound shortcut lists its new keys in the shortcuts cheat sheet", async ({ page }) => {
|
||||
await openShortcutsSettings(page);
|
||||
|
||||
const defaultKeys = page.getByText("?", { exact: true });
|
||||
await expect(defaultKeys).toBeVisible();
|
||||
|
||||
await page.getByTestId(`shortcut-actions-${SHORTCUTS_ROW}`).click();
|
||||
await page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`).click();
|
||||
await page.keyboard.press("Alt+Shift+K");
|
||||
await page.getByText("Done", { exact: true }).click();
|
||||
|
||||
const reboundKeys = page.getByText("⌥+Shift+K", { exact: true });
|
||||
await expect(reboundKeys).toBeVisible();
|
||||
await expect(defaultKeys).toHaveCount(0);
|
||||
|
||||
const dialog = await openCheatSheet(page);
|
||||
const row = dialog.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`);
|
||||
await expect(row.getByText("⌥+Shift+K", { exact: true })).toBeVisible();
|
||||
// The whole point: the cheat sheet stops advertising the shipped default.
|
||||
await expect(row.getByText("?", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, test } from "../../app/e2e/support/fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
startIsolatedHostDaemon,
|
||||
type IsolatedHostDaemon,
|
||||
@@ -19,12 +18,11 @@ import {
|
||||
openPairDeviceFromHome,
|
||||
openRelaySecurityDocs,
|
||||
observePairingOfferRequests,
|
||||
preparePairingHost,
|
||||
prepareLocalPairingHost,
|
||||
reloadAndOpenPairDevice,
|
||||
retryRelayAndExpectFailure,
|
||||
switchPairDeviceToHost,
|
||||
} from "../../app/e2e/support/helpers/pair-device";
|
||||
import { installDesktopRuntime } from "./support/runtime";
|
||||
|
||||
interface RelayConfigDaemonClient {
|
||||
close(): Promise<void>;
|
||||
@@ -32,20 +30,6 @@ interface RelayConfigDaemonClient {
|
||||
patchDaemonConfig(config: { relay: { enabled: boolean } }): Promise<unknown>;
|
||||
}
|
||||
|
||||
async function prepareLocalPairingHost(
|
||||
page: Page,
|
||||
daemon: Parameters<typeof preparePairingHost>[1],
|
||||
additionalHosts: Parameters<typeof preparePairingHost>[2] = [],
|
||||
): Promise<void> {
|
||||
const endpoint = "port" in daemon ? `127.0.0.1:${daemon.port}` : daemon.endpoint;
|
||||
await installDesktopRuntime(page, {
|
||||
serverId: daemon.serverId,
|
||||
daemonListen: endpoint,
|
||||
manageBuiltInDaemon: false,
|
||||
});
|
||||
await preparePairingHost(page, daemon, additionalHosts);
|
||||
}
|
||||
|
||||
test.describe("local device relay pairing", () => {
|
||||
let relayOffDaemon: IsolatedHostDaemon;
|
||||
let relayEnableDaemon: IsolatedHostDaemon;
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { test, expect } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
|
||||
import { getServerId } from "../../app/e2e/support/helpers/server-id";
|
||||
import { installDesktopRuntime } from "./support/runtime";
|
||||
|
||||
test.describe("Settings sidebar scrolling", () => {
|
||||
test.use({ viewport: { width: 900, height: 260 } });
|
||||
|
||||
test("desktop drag region does not cover the scroll body", async ({ page }) => {
|
||||
await installDesktopRuntime(page, {
|
||||
serverId: getServerId(),
|
||||
manageBuiltInDaemon: true,
|
||||
await page.addInitScript(() => {
|
||||
window.paseoDesktop = {
|
||||
platform: "darwin",
|
||||
events: { on: () => () => {} },
|
||||
invoke: async (command: string) => {
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -139,123 +139,8 @@ export async function installDesktopRuntime(
|
||||
let daemonRunning = true;
|
||||
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||
let startCount = 0;
|
||||
let nextTransportSessionId = 0;
|
||||
const transportSessions = new Map<string, WebSocket>();
|
||||
const desktopEventHandlers = new Map<string, Set<(payload: unknown) => void>>();
|
||||
window.__desktopDaemonStartRequested = false;
|
||||
|
||||
function emitDesktopEvent(event: string, payload: unknown) {
|
||||
for (const handler of desktopEventHandlers.get(event) ?? []) {
|
||||
handler(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseWebSocketTransportTarget(args?: Record<string, unknown>): {
|
||||
url: string;
|
||||
protocols: string[];
|
||||
} {
|
||||
const url = typeof args?.url === "string" ? args.url : "";
|
||||
const protocols = Array.isArray(args?.protocols)
|
||||
? args.protocols.filter((protocol): protocol is string => typeof protocol === "string")
|
||||
: [];
|
||||
const headers =
|
||||
args?.headers && typeof args.headers === "object"
|
||||
? Object.entries(args.headers as Record<string, unknown>)
|
||||
: [];
|
||||
const passwordProtocol = protocols.find((protocol) => protocol.startsWith("paseo.bearer."));
|
||||
const supportsHeaders =
|
||||
headers.length === 0 ||
|
||||
(headers.length === 1 &&
|
||||
headers[0][0].toLowerCase() === "authorization" &&
|
||||
typeof headers[0][1] === "string" &&
|
||||
passwordProtocol !== undefined &&
|
||||
headers[0][1] === `Bearer ${passwordProtocol.slice("paseo.bearer.".length)}`);
|
||||
|
||||
if (!url || !supportsHeaders) {
|
||||
throw new Error("The browser-backed desktop E2E transport cannot attach custom headers.");
|
||||
}
|
||||
return { url, protocols };
|
||||
}
|
||||
|
||||
async function openWebSocketTransport(args?: Record<string, unknown>): Promise<string> {
|
||||
const target = parseWebSocketTransportTarget(args);
|
||||
const sessionId = `e2e-websocket-session-${++nextTransportSessionId}`;
|
||||
const socket = new WebSocket(target.url, target.protocols);
|
||||
socket.binaryType = "arraybuffer";
|
||||
transportSessions.set(sessionId, socket);
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
let opened = false;
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
opened = true;
|
||||
resolve(sessionId);
|
||||
emitDesktopEvent("local-daemon-transport-event", { sessionId, kind: "open" });
|
||||
});
|
||||
socket.addEventListener("message", async (event) => {
|
||||
if (typeof event.data === "string") {
|
||||
emitDesktopEvent("local-daemon-transport-event", {
|
||||
sessionId,
|
||||
kind: "message",
|
||||
text: event.data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const buffer =
|
||||
event.data instanceof ArrayBuffer
|
||||
? event.data
|
||||
: await (event.data as Blob).arrayBuffer();
|
||||
emitDesktopEvent("local-daemon-transport-event", {
|
||||
sessionId,
|
||||
kind: "message",
|
||||
binaryBase64: bytesToBase64(new Uint8Array(buffer)),
|
||||
});
|
||||
});
|
||||
socket.addEventListener("close", (event) => {
|
||||
transportSessions.delete(sessionId);
|
||||
if (!opened) {
|
||||
reject(new Error("Desktop E2E WebSocket closed before opening."));
|
||||
return;
|
||||
}
|
||||
emitDesktopEvent("local-daemon-transport-event", {
|
||||
sessionId,
|
||||
kind: "close",
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
});
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
if (!opened) {
|
||||
transportSessions.delete(sessionId);
|
||||
reject(new Error("Desktop E2E WebSocket failed to open."));
|
||||
return;
|
||||
}
|
||||
emitDesktopEvent("local-daemon-transport-event", {
|
||||
sessionId,
|
||||
kind: "error",
|
||||
error: "Desktop E2E WebSocket transport error.",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildDaemonStatus() {
|
||||
return {
|
||||
serverId: cfg.serverId,
|
||||
@@ -290,55 +175,6 @@ export async function installDesktopRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
function patchDesktopSettings(args?: Record<string, unknown>) {
|
||||
const daemon = args?.daemon;
|
||||
if (
|
||||
daemon !== null &&
|
||||
typeof daemon === "object" &&
|
||||
"manageBuiltInDaemon" in daemon &&
|
||||
typeof daemon.manageBuiltInDaemon === "boolean"
|
||||
) {
|
||||
manageDaemon = daemon.manageBuiltInDaemon;
|
||||
}
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
|
||||
function sendWebSocketTransportMessage(args?: Record<string, unknown>): null {
|
||||
const sessionId = typeof args?.sessionId === "string" ? args.sessionId : "";
|
||||
const socket = transportSessions.get(sessionId);
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error(`Desktop E2E WebSocket session is not open: ${sessionId}`);
|
||||
}
|
||||
if (typeof args?.text === "string") {
|
||||
socket.send(args.text);
|
||||
return null;
|
||||
}
|
||||
if (typeof args?.binaryBase64 === "string") {
|
||||
socket.send(base64ToBytes(args.binaryBase64));
|
||||
return null;
|
||||
}
|
||||
throw new Error("Desktop E2E WebSocket send requires a payload.");
|
||||
}
|
||||
|
||||
function closeWebSocketTransport(args?: Record<string, unknown>): null {
|
||||
const sessionId = typeof args?.sessionId === "string" ? args.sessionId : "";
|
||||
transportSessions.get(sessionId)?.close();
|
||||
transportSessions.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const webSocketTransportCommandHandlers: Record<
|
||||
string,
|
||||
(args?: Record<string, unknown>) => unknown
|
||||
> = {
|
||||
open_websocket_daemon_transport: openWebSocketTransport,
|
||||
send_local_daemon_transport_message: sendWebSocketTransportMessage,
|
||||
close_local_daemon_transport: closeWebSocketTransport,
|
||||
};
|
||||
|
||||
const desktopBridge: {
|
||||
platform: string;
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -347,10 +183,7 @@ export async function installDesktopRuntime(
|
||||
open: (options?: Record<string, unknown>) => Promise<string | string[] | null>;
|
||||
};
|
||||
getPendingOpenProject: () => Promise<string | null>;
|
||||
opener: { openUrl: (url: string) => Promise<void> };
|
||||
events: {
|
||||
on: (event: string, handler: (payload: unknown) => void) => Promise<() => void>;
|
||||
};
|
||||
events: { on: () => Promise<() => void> };
|
||||
editor?: {
|
||||
listTargets: () => Promise<DesktopEditorTargetConfig[]>;
|
||||
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
@@ -406,7 +239,19 @@ export async function installDesktopRuntime(
|
||||
}
|
||||
|
||||
if (command === "patch_desktop_settings") {
|
||||
return patchDesktopSettings(args);
|
||||
const daemon = args?.daemon;
|
||||
if (
|
||||
daemon !== null &&
|
||||
typeof daemon === "object" &&
|
||||
"manageBuiltInDaemon" in daemon &&
|
||||
typeof daemon.manageBuiltInDaemon === "boolean"
|
||||
) {
|
||||
manageDaemon = daemon.manageBuiltInDaemon;
|
||||
}
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "stop_desktop_daemon") {
|
||||
@@ -419,11 +264,6 @@ export async function installDesktopRuntime(
|
||||
return startDesktopDaemon();
|
||||
}
|
||||
|
||||
const transportCommandHandler = webSocketTransportCommandHandlers[command];
|
||||
if (transportCommandHandler) {
|
||||
return await transportCommandHandler(args);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
dialog: {
|
||||
@@ -440,24 +280,7 @@ export async function installDesktopRuntime(
|
||||
},
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
opener: {
|
||||
openUrl: async (url) => {
|
||||
localStorage.setItem("@paseo:e2e-opened-url", url);
|
||||
},
|
||||
},
|
||||
events: {
|
||||
on: async (event, handler) => {
|
||||
const handlers = desktopEventHandlers.get(event) ?? new Set();
|
||||
handlers.add(handler);
|
||||
desktopEventHandlers.set(event, handlers);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
desktopEventHandlers.delete(event);
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
events: { on: async () => () => undefined },
|
||||
};
|
||||
|
||||
if (cfg.editorTargets) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -26,10 +26,8 @@ import {
|
||||
import { createSkillsCommandHandlers, getSkillsController } from "../integrations/skills/index.js";
|
||||
import {
|
||||
openLocalTransportSession,
|
||||
openWebSocketTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
closeLocalTransportSession,
|
||||
type WebSocketTransportTarget,
|
||||
} from "./local-transport.js";
|
||||
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
|
||||
import { runExternalCliJsonCommand, runExternalCliTextCommand } from "./cli/external.js";
|
||||
@@ -539,9 +537,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
const target = args as { transportType: "socket" | "pipe"; transportPath: string };
|
||||
return await openLocalTransportSession(target);
|
||||
},
|
||||
open_websocket_daemon_transport: async (args) => {
|
||||
return await openWebSocketTransportSession(args as unknown as WebSocketTransportTarget);
|
||||
},
|
||||
send_local_daemon_transport_message: async (args) => {
|
||||
await sendLocalTransportMessage(
|
||||
args as { sessionId: string; text?: string; binaryBase64?: string },
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { createServer } from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const send = vi.fn();
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => [{ webContents: { send } }],
|
||||
},
|
||||
}));
|
||||
|
||||
import { closeAllTransportSessions, openWebSocketTransportSession } from "./local-transport";
|
||||
|
||||
afterEach(() => {
|
||||
closeAllTransportSessions();
|
||||
send.mockReset();
|
||||
});
|
||||
|
||||
describe("desktop WebSocket transport", () => {
|
||||
it("attaches custom headers when opening a remote daemon session", async () => {
|
||||
const server = createServer();
|
||||
const webSocketServer = new WebSocketServer({ server });
|
||||
const requestHeaders = new Promise<Record<string, string | string[] | undefined>>((resolve) => {
|
||||
webSocketServer.once("connection", (_socket, request) => resolve(request.headers));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected TCP test server address.");
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionId = await openWebSocketTransportSession({
|
||||
transportType: "websocket",
|
||||
url: `ws://127.0.0.1:${address.port}/ws`,
|
||||
headers: { "X-Tenant": "acme" },
|
||||
});
|
||||
|
||||
expect((await requestHeaders)["x-tenant"]).toBe("acme");
|
||||
expect(sessionId).toMatch(/^local-session-/);
|
||||
} finally {
|
||||
closeAllTransportSessions();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
webSocketServer.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-WebSocket URLs", () => {
|
||||
expect(() =>
|
||||
openWebSocketTransportSession({
|
||||
transportType: "websocket",
|
||||
url: "https://daemon.example/ws",
|
||||
}),
|
||||
).toThrow("Unsupported WebSocket transport URL");
|
||||
});
|
||||
|
||||
it("rejects malformed custom headers before opening a session", () => {
|
||||
expect(() =>
|
||||
openWebSocketTransportSession({
|
||||
transportType: "websocket",
|
||||
url: "ws://daemon.example/ws",
|
||||
headers: { "Bad Header": "value" },
|
||||
}),
|
||||
).toThrow("Invalid WebSocket transport header");
|
||||
|
||||
expect(() =>
|
||||
openWebSocketTransportSession({
|
||||
transportType: "websocket",
|
||||
url: "ws://daemon.example/ws",
|
||||
headers: { "X-Tenant": "acme\r\nX-Injected: true" },
|
||||
}),
|
||||
).toThrow("Invalid WebSocket transport header");
|
||||
});
|
||||
});
|
||||
@@ -6,13 +6,6 @@ interface TransportTarget {
|
||||
transportPath: string;
|
||||
}
|
||||
|
||||
export interface WebSocketTransportTarget {
|
||||
transportType: "websocket";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
interface TransportEventPayload {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
@@ -30,7 +23,6 @@ interface Session {
|
||||
}
|
||||
|
||||
const WS_ENDPOINT_PATH = "/ws";
|
||||
const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
|
||||
let nextSessionId = 0;
|
||||
const sessions = new Map<string, Session>();
|
||||
@@ -60,28 +52,6 @@ function describeTransportTarget(target: TransportTarget): string {
|
||||
return target.transportType === "pipe" ? "local daemon pipe" : "local daemon socket";
|
||||
}
|
||||
|
||||
function normalizeWebSocketTarget(target: WebSocketTransportTarget): WebSocketTransportTarget {
|
||||
const parsed = new URL(target.url);
|
||||
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
||||
throw new Error(`Unsupported WebSocket transport URL: ${target.url}`);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(target.headers ?? {})) {
|
||||
if (!HTTP_HEADER_NAME_PATTERN.test(name) || typeof value !== "string" || /[\r\n]/.test(value)) {
|
||||
throw new Error("Invalid WebSocket transport header.");
|
||||
}
|
||||
headers[name] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
transportType: "websocket",
|
||||
url: parsed.toString(),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(target.protocols?.length ? { protocols: [...target.protocols] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeTransportMessage(input: { text?: string; binaryBase64?: string }): string | Buffer {
|
||||
if (typeof input.text === "string") {
|
||||
return input.text;
|
||||
@@ -95,28 +65,11 @@ function decodeTransportMessage(input: { text?: string; binaryBase64?: string })
|
||||
}
|
||||
|
||||
export function openLocalTransportSession(target: TransportTarget): Promise<string> {
|
||||
return openTransportSession(buildLocalWebSocketUrl(target), describeTransportTarget(target));
|
||||
}
|
||||
|
||||
export function openWebSocketTransportSession(target: WebSocketTransportTarget): Promise<string> {
|
||||
const normalized = normalizeWebSocketTarget(target);
|
||||
return openTransportSession(normalized.url, "remote daemon", {
|
||||
headers: normalized.headers,
|
||||
protocols: normalized.protocols,
|
||||
});
|
||||
}
|
||||
|
||||
function openTransportSession(
|
||||
url: string,
|
||||
targetDescription: string,
|
||||
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
): Promise<string> {
|
||||
const sessionId = `local-session-${++nextSessionId}`;
|
||||
const url = buildLocalWebSocketUrl(target);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = options?.protocols
|
||||
? new WebSocket(url, options.protocols, { headers: options.headers })
|
||||
: new WebSocket(url, { headers: options?.headers });
|
||||
const ws = new WebSocket(url);
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ws,
|
||||
@@ -168,7 +121,9 @@ function openTransportSession(
|
||||
sessions.delete(sessionId);
|
||||
|
||||
if (!openSettled) {
|
||||
finalizeOpenFailure(`${targetDescription} closed before the session became ready.`);
|
||||
finalizeOpenFailure(
|
||||
`${describeTransportTarget(target)} closed before the session became ready.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -184,7 +139,9 @@ function openTransportSession(
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
if (!openSettled) {
|
||||
finalizeOpenFailure(`Failed to connect to ${targetDescription}: ${err.message}`);
|
||||
finalizeOpenFailure(
|
||||
`Failed to connect to ${describeTransportTarget(target)}: ${err.message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,24 +4,99 @@ import path from "node:path";
|
||||
import { UUID } from "builder-util-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { autoUpdaterMock } = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (value: unknown) => void>();
|
||||
return {
|
||||
autoUpdaterMock: {
|
||||
handlers,
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn((message: unknown) => console.error(message)),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
checkForUpdates: vi.fn(),
|
||||
downloadUpdate: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (value: unknown) => void) => {
|
||||
handlers.set(event, handler);
|
||||
}),
|
||||
quitAndInstall: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(),
|
||||
isPackaged: true,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("electron-updater", () => ({
|
||||
autoUpdater: {},
|
||||
autoUpdater: autoUpdaterMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
bucketFromStagingUserId,
|
||||
checkForAppUpdate,
|
||||
resolveStagingUserId,
|
||||
rolloutManifestSchema,
|
||||
shouldAdmitToRollout,
|
||||
shouldInstallAppUpdateOnQuit,
|
||||
} from "./auto-updater";
|
||||
|
||||
describe("checkForAppUpdate", () => {
|
||||
it("treats an unpublished channel manifest as an unavailable update", async () => {
|
||||
const error = Object.assign(new Error("Cannot find latest-mac.yml"), {
|
||||
code: "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND",
|
||||
});
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
autoUpdaterMock.checkForUpdates.mockImplementationOnce(async () => {
|
||||
autoUpdaterMock.logger.error(error);
|
||||
autoUpdaterMock.handlers.get("error")?.(error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.3",
|
||||
body: null,
|
||||
date: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps genuine updater failures visible", async () => {
|
||||
const error = new Error("network down");
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
autoUpdaterMock.checkForUpdates.mockImplementationOnce(async () => {
|
||||
autoUpdaterMock.logger.error(error);
|
||||
autoUpdaterMock.handlers.get("error")?.(error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
expect(result.errorMessage).toBe("network down");
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldInstallAppUpdateOnQuit", () => {
|
||||
it("keeps Linux AppImage updates on the manual install path", () => {
|
||||
expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: true })).toBe(false);
|
||||
|
||||
@@ -33,6 +33,17 @@ export {
|
||||
|
||||
let cachedStagingUserIdPromise: Promise<string> | null = null;
|
||||
|
||||
const UPDATE_CHANNEL_NOT_PUBLISHED_CODE = "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND";
|
||||
|
||||
function isUpdateChannelNotPublished(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === UPDATE_CHANNEL_NOT_PUBLISHED_CODE
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldAdmitToRollout(args: {
|
||||
channel: AppReleaseChannel;
|
||||
rolloutHours: number | undefined;
|
||||
@@ -109,6 +120,18 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime {
|
||||
if (this.configured) return;
|
||||
this.configured = true;
|
||||
|
||||
// electron-updater logs every emitted error before consumers can classify it.
|
||||
// Paseo reports genuine check, runtime, and install failures through the
|
||||
// callbacks below, so leave internal error logging disabled to avoid both
|
||||
// duplicate logs and expected missing-channel noise.
|
||||
const updaterLogger = autoUpdater.logger;
|
||||
autoUpdater.logger = {
|
||||
debug: updaterLogger?.debug ? (message) => updaterLogger.debug?.(message) : undefined,
|
||||
error: () => undefined,
|
||||
info: (message) => updaterLogger?.info(message),
|
||||
warn: (message) => updaterLogger?.warn(message),
|
||||
};
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
input.onUpdateAvailable(info as RuntimeUpdateInfo);
|
||||
});
|
||||
@@ -119,17 +142,23 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime {
|
||||
input.onUpdateNotAvailable();
|
||||
});
|
||||
autoUpdater.on("error", (error) => {
|
||||
if (isUpdateChannelNotPublished(error)) return;
|
||||
input.onError(error);
|
||||
});
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<RuntimeUpdateCheckResult | null> {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
if (!result) return null;
|
||||
return {
|
||||
isUpdateAvailable: result.isUpdateAvailable,
|
||||
updateInfo: result.updateInfo as RuntimeUpdateInfo,
|
||||
};
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
if (!result) return null;
|
||||
return {
|
||||
isUpdateAvailable: result.isUpdateAvailable,
|
||||
updateInfo: result.updateInfo as RuntimeUpdateInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isUpdateChannelNotPublished(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
downloadUpdate(): Promise<unknown> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -6,7 +6,6 @@ export const DirectTcpHostConnectionSchema = z.object({
|
||||
endpoint: z.string(),
|
||||
useTls: z.boolean().optional().default(false),
|
||||
password: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export type DirectTcpHostConnection = z.input<typeof DirectTcpHostConnectionSchema>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -69,10 +69,10 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.3.0",
|
||||
"@getpaseo/highlight": "0.3.0",
|
||||
"@getpaseo/protocol": "0.3.0",
|
||||
"@getpaseo/relay": "0.3.0",
|
||||
"@getpaseo/client": "0.3.1",
|
||||
"@getpaseo/highlight": "0.3.1",
|
||||
"@getpaseo/protocol": "0.3.1",
|
||||
"@getpaseo/relay": "0.3.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -13,9 +13,11 @@ const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url)
|
||||
async function runSupervisorFixture(options: {
|
||||
workerSource: string;
|
||||
restartOnCrash?: boolean;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
elapsedMs: number;
|
||||
log: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
@@ -47,6 +49,7 @@ async function runSupervisorFixture(options: {
|
||||
`,
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env },
|
||||
@@ -71,7 +74,7 @@ async function runSupervisorFixture(options: {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("supervisor fixture timed out"));
|
||||
}, 10000);
|
||||
}, options.timeoutMs ?? 10_000);
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
@@ -84,7 +87,7 @@ async function runSupervisorFixture(options: {
|
||||
});
|
||||
|
||||
const log = await readFile(logPath, "utf8");
|
||||
return { code, signal, log, stdout, stderr };
|
||||
return { code, signal, elapsedMs: Date.now() - startedAt, log, stdout, stderr };
|
||||
}
|
||||
|
||||
describe("supervisor durable logging", () => {
|
||||
@@ -186,6 +189,152 @@ describe("supervisor durable logging", () => {
|
||||
expect(result.log).toContain('"workerPid":');
|
||||
});
|
||||
|
||||
test("keeps a worker alive while heartbeats continue", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
timeoutMs: 10_000,
|
||||
workerSource: `
|
||||
setInterval(() => {
|
||||
process.send?.({ type: "paseo:worker-heartbeat" });
|
||||
}, 250);
|
||||
setTimeout(() => {
|
||||
process.send?.({ type: "paseo:shutdown", reason: "healthy_heartbeat_test_complete" });
|
||||
}, 6000);
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"reason":"healthy_heartbeat_test_complete"');
|
||||
expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"');
|
||||
}, 10_000);
|
||||
|
||||
test("tolerates a transient seven-second heartbeat pause", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
timeoutMs: 10_000,
|
||||
workerSource: `
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
|
||||
const marker = process.argv[1] + ".started";
|
||||
if (!existsSync(marker)) {
|
||||
writeFileSync(marker, "started");
|
||||
let heartbeatCount = 0;
|
||||
const heartbeat = setInterval(() => {
|
||||
process.send?.({ type: "paseo:worker-heartbeat" });
|
||||
heartbeatCount += 1;
|
||||
if (heartbeatCount === 3) clearInterval(heartbeat);
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
process.send?.({ type: "paseo:shutdown", reason: "heartbeat_pause_tolerated" });
|
||||
}, 7_000);
|
||||
setInterval(() => {}, 1_000);
|
||||
} else {
|
||||
process.send?.({ type: "paseo:shutdown", reason: "unexpected_heartbeat_restart" });
|
||||
setInterval(() => {}, 1_000);
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"reason":"heartbeat_pause_tolerated"');
|
||||
expect(result.log).not.toContain('"reason":"unexpected_heartbeat_restart"');
|
||||
expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"');
|
||||
}, 10_000);
|
||||
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"forces shutdown when a worker ignores SIGTERM",
|
||||
async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
timeoutMs: 15_000,
|
||||
workerSource: `
|
||||
process.on("SIGTERM", () => {});
|
||||
process.send?.({ type: "paseo:shutdown", reason: "stalled_worker_shutdown" });
|
||||
setInterval(() => {}, 1_000);
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"reason":"stalled_worker_shutdown"');
|
||||
expect(result.log).toContain('"msg":"Worker did not exit after SIGTERM; forcing SIGKILL"');
|
||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
|
||||
// POSIX-only: the watchdog uses SIGKILL after its graceful shutdown window.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"restarts a worker that stops heartbeating",
|
||||
async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
timeoutMs: 35_000,
|
||||
workerSource: `
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
|
||||
const marker = process.argv[1] + ".started";
|
||||
if (!existsSync(marker)) {
|
||||
writeFileSync(marker, "started");
|
||||
let heartbeatCount = 0;
|
||||
const heartbeat = setInterval(() => {
|
||||
process.send?.({ type: "paseo:worker-heartbeat" });
|
||||
heartbeatCount += 1;
|
||||
if (heartbeatCount === 3) clearInterval(heartbeat);
|
||||
}, 100);
|
||||
process.on("SIGTERM", () => {});
|
||||
setInterval(() => {}, 1000);
|
||||
} else {
|
||||
process.send?.({ type: "paseo:shutdown", reason: "watchdog_test_complete" });
|
||||
setInterval(() => {}, 1000);
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.log).toContain('"msg":"Worker heartbeat timed out; restarting worker"');
|
||||
expect(result.log).toContain('"msg":"Worker did not exit after SIGTERM; forcing SIGKILL"');
|
||||
expect(result.log).toContain('"signal":"SIGKILL"');
|
||||
},
|
||||
40_000,
|
||||
);
|
||||
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"restarts after worker exit while a descendant retains the worker stdio",
|
||||
async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
timeoutMs: 7_000,
|
||||
workerSource: `
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
|
||||
const marker = process.argv[1] + ".started";
|
||||
if (!existsSync(marker)) {
|
||||
writeFileSync(marker, "started");
|
||||
const descendant = spawn(
|
||||
process.execPath,
|
||||
["-e", "process.on('SIGTERM', () => {}); setTimeout(() => process.exit(0), 4000)"],
|
||||
{ detached: true, stdio: ["ignore", "inherit", "inherit"] },
|
||||
);
|
||||
descendant.unref();
|
||||
process.on("SIGTERM", () => process.exit(0));
|
||||
process.send?.({ type: "paseo:restart", reason: "stdio_descendant" });
|
||||
setInterval(() => {}, 1000);
|
||||
} else {
|
||||
process.send?.({ type: "paseo:shutdown", reason: "stdio_restart_complete" });
|
||||
setInterval(() => {}, 1000);
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.elapsedMs).toBeLessThan(2_500);
|
||||
expect(result.log).toContain('"reason":"stdio_descendant"');
|
||||
expect(result.log).toContain("Restarting worker");
|
||||
},
|
||||
7_000,
|
||||
);
|
||||
|
||||
// POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"logs worker signal exits even when the worker cannot log",
|
||||
|
||||
@@ -2,6 +2,11 @@ import { fork, spawn, type ChildProcess } from "child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createStream as createRotatingFileStream } from "rotating-file-stream";
|
||||
import { signalProcessTree } from "../src/utils/tree-kill.js";
|
||||
|
||||
const WORKER_HEARTBEAT_INTERVAL_MS = 1_000;
|
||||
const WORKER_HEARTBEAT_TIMEOUT_MS = 15_000;
|
||||
const WORKER_TERMINATION_GRACE_MS = 10_000;
|
||||
|
||||
interface SupervisorLogFileOptions {
|
||||
path: string;
|
||||
@@ -29,6 +34,10 @@ interface SupervisorHeartbeatMessage {
|
||||
type: "paseo:supervisor-heartbeat";
|
||||
}
|
||||
|
||||
interface WorkerHeartbeatMessage {
|
||||
type: "paseo:worker-heartbeat";
|
||||
}
|
||||
|
||||
interface SupervisorOptions {
|
||||
name: string;
|
||||
startupMessage: string;
|
||||
@@ -84,6 +93,15 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isWorkerHeartbeatMessage(msg: unknown): msg is WorkerHeartbeatMessage {
|
||||
return (
|
||||
typeof msg === "object" &&
|
||||
msg !== null &&
|
||||
"type" in msg &&
|
||||
(msg as { type?: unknown }).type === "paseo:worker-heartbeat"
|
||||
);
|
||||
}
|
||||
|
||||
function toRotatingFileStreamSize(size: string): string {
|
||||
const trimmed = size.trim();
|
||||
const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/);
|
||||
@@ -120,6 +138,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
let restarting = false;
|
||||
let shuttingDown = false;
|
||||
let exiting = false;
|
||||
let forceKillTimer: NodeJS.Timeout | null = null;
|
||||
const logStream = createSupervisorLogStream(options.logFile);
|
||||
|
||||
const writeDurableChunk = (chunk: string | Buffer): void => {
|
||||
@@ -169,6 +188,40 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
});
|
||||
};
|
||||
|
||||
const clearForceKillTimer = (): void => {
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
forceKillTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleForceKill = (reason: string): void => {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
const currentChild = child;
|
||||
clearForceKillTimer();
|
||||
forceKillTimer = setTimeout(() => {
|
||||
forceKillTimer = null;
|
||||
if (child !== currentChild) {
|
||||
return;
|
||||
}
|
||||
writeLifecycleLog("Worker did not exit after SIGTERM; forcing SIGKILL", {
|
||||
reason,
|
||||
supervisorPid: process.pid,
|
||||
workerPid: currentChild.pid ?? null,
|
||||
});
|
||||
void signalProcessTree(currentChild, "SIGKILL").catch((error) => {
|
||||
writeLifecycleLog("Failed to force-kill worker process tree", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
supervisorPid: process.pid,
|
||||
workerPid: currentChild.pid ?? null,
|
||||
});
|
||||
});
|
||||
}, WORKER_TERMINATION_GRACE_MS);
|
||||
forceKillTimer.unref();
|
||||
};
|
||||
|
||||
const spawnWorker = () => {
|
||||
let workerEntry: string;
|
||||
try {
|
||||
@@ -197,6 +250,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
}
|
||||
|
||||
const currentChild = child;
|
||||
let lastWorkerHeartbeatAt = Date.now();
|
||||
const heartbeat = setInterval(() => {
|
||||
const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" };
|
||||
if (currentChild.connected) {
|
||||
@@ -210,9 +264,26 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
} else {
|
||||
writeLifecycleLog("Worker heartbeat skipped because IPC channel is disconnected");
|
||||
}
|
||||
}, 1000);
|
||||
}, WORKER_HEARTBEAT_INTERVAL_MS);
|
||||
heartbeat.unref();
|
||||
|
||||
const workerWatchdog = setInterval(() => {
|
||||
if (child !== currentChild || restarting || shuttingDown) {
|
||||
return;
|
||||
}
|
||||
const heartbeatAgeMs = Date.now() - lastWorkerHeartbeatAt;
|
||||
if (heartbeatAgeMs < WORKER_HEARTBEAT_TIMEOUT_MS) {
|
||||
return;
|
||||
}
|
||||
writeLifecycleLog("Worker heartbeat timed out; restarting worker", {
|
||||
heartbeatAgeMs,
|
||||
supervisorPid: process.pid,
|
||||
workerPid: currentChild.pid ?? null,
|
||||
});
|
||||
requestRestart("worker_heartbeat_timeout");
|
||||
}, WORKER_HEARTBEAT_INTERVAL_MS);
|
||||
workerWatchdog.unref();
|
||||
|
||||
child.on("disconnect", () => {
|
||||
writeLifecycleLog("Worker IPC channel disconnected");
|
||||
});
|
||||
@@ -228,6 +299,10 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
});
|
||||
|
||||
child.on("message", (msg: unknown) => {
|
||||
if (isWorkerHeartbeatMessage(msg)) {
|
||||
lastWorkerHeartbeatAt = Date.now();
|
||||
return;
|
||||
}
|
||||
const lifecycleMessage = parseLifecycleMessage(msg);
|
||||
if (!lifecycleMessage) {
|
||||
return;
|
||||
@@ -256,8 +331,10 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
requestRestart(reason);
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
child.on("exit", (code, signal) => {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(workerWatchdog);
|
||||
clearForceKillTimer();
|
||||
const exitDescriptor = describeExit(code, signal);
|
||||
writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor });
|
||||
|
||||
@@ -308,6 +385,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
writeLifecycleLog("Restart requested", { reason });
|
||||
log(`${reason}. Stopping worker for restart...`);
|
||||
signalWorker("SIGTERM", reason);
|
||||
scheduleForceKill(reason);
|
||||
};
|
||||
|
||||
const requestShutdown = (reason: string) => {
|
||||
@@ -323,6 +401,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
return;
|
||||
}
|
||||
signalWorker("SIGTERM", reason);
|
||||
scheduleForceKill(reason);
|
||||
};
|
||||
|
||||
const forwardSignal = (signal: NodeJS.Signals) => {
|
||||
|
||||
@@ -22,6 +22,22 @@ function createRealAgentManager(storage: AgentStorage): AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
async function removeRealAgentManagerWorkdir({
|
||||
agentManager,
|
||||
storage,
|
||||
workdir,
|
||||
}: {
|
||||
agentManager: AgentManager;
|
||||
storage: AgentStorage;
|
||||
workdir: string;
|
||||
}): Promise<void> {
|
||||
agentManager.prepareForShutdown();
|
||||
await Promise.all(agentManager.listAgents().map((agent) => agentManager.closeAgent(agent.id)));
|
||||
await agentManager.flushForShutdown();
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Creates a worktree directory under repoRoot and reports it back as a fresh
|
||||
// workspace so the command can stamp the agent with it (mirrors the production
|
||||
// worktree service).
|
||||
@@ -238,7 +254,7 @@ test("session create stamps the requested workspaceId when no worktree setup run
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.workspaceId).toBe("ws-source");
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
await removeRealAgentManagerWorkdir({ agentManager, storage, workdir });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -273,7 +289,7 @@ test("session create stamps the new worktree's workspaceId when a setup continua
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.workspaceId).toBe("ws-new-worktree");
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
await removeRealAgentManagerWorkdir({ agentManager, storage, workdir });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -324,7 +340,7 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
|
||||
expect(storedChild?.workspaceId).toBe("ws-new-worktree");
|
||||
expect(child.cwd).toBe(join(workdir, "worktree", "packages", "app"));
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
await removeRealAgentManagerWorkdir({ agentManager, storage, workdir });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -376,7 +392,7 @@ test("mcp create exposes the created worktree before dispatching the initial pro
|
||||
|
||||
expect(observed).toEqual({ createdWorktree, lifecycle: "idle" });
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
await removeRealAgentManagerWorkdir({ agentManager, storage, workdir });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -414,7 +430,7 @@ test("session create keeps the prompt title after the initial prompt settles", a
|
||||
const settled = await storage.get(snapshot.id);
|
||||
expect(settled?.title).toBe(title);
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
await removeRealAgentManagerWorkdir({ agentManager, storage, workdir });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -452,6 +468,6 @@ test("session create keeps an explicit title after the initial prompt settles",
|
||||
const settled = await storage.get(snapshot.id);
|
||||
expect(settled?.title).toBe(title);
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
await removeRealAgentManagerWorkdir({ agentManager, storage, workdir });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,6 +27,10 @@ interface SupervisorHeartbeatMessage {
|
||||
type: "paseo:supervisor-heartbeat";
|
||||
}
|
||||
|
||||
interface WorkerHeartbeatMessage {
|
||||
type: "paseo:worker-heartbeat";
|
||||
}
|
||||
|
||||
interface BootstrapResult {
|
||||
paseoHome: string;
|
||||
logger: ReturnType<typeof createRootLogger>;
|
||||
@@ -228,6 +232,13 @@ async function main() {
|
||||
const supervisorPid = process.ppid;
|
||||
let lastSupervisorHeartbeatAt = Date.now();
|
||||
let supervisorExitRequested = false;
|
||||
const sendWorkerHeartbeat = () => {
|
||||
try {
|
||||
process.send?.({ type: "paseo:worker-heartbeat" } satisfies WorkerHeartbeatMessage);
|
||||
} catch {
|
||||
// The disconnect handler below owns supervisor-loss shutdown.
|
||||
}
|
||||
};
|
||||
const exitAfterSupervisorLoss = (reason: string) => {
|
||||
if (supervisorExitRequested) {
|
||||
return;
|
||||
@@ -261,6 +272,10 @@ async function main() {
|
||||
});
|
||||
process.on("disconnect", () => exitAfterSupervisorLoss("ipc_disconnect_event"));
|
||||
|
||||
sendWorkerHeartbeat();
|
||||
const workerHeartbeat = setInterval(sendWorkerHeartbeat, 1_000);
|
||||
workerHeartbeat.unref();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const ipcConnected = typeof process.connected === "boolean" ? process.connected : true;
|
||||
const heartbeatExpired = Date.now() - lastSupervisorHeartbeatAt > 3500;
|
||||
|
||||
@@ -203,7 +203,10 @@ class ControlledAgentClient implements AgentClient {
|
||||
resumes = 0;
|
||||
createdConfigs: AgentSessionConfig[] = [];
|
||||
|
||||
constructor(private readonly client: AgentClient) {
|
||||
constructor(
|
||||
private readonly client: AgentClient,
|
||||
private readonly internalClient: AgentClient,
|
||||
) {
|
||||
this.provider = client.provider;
|
||||
this.capabilities = client.capabilities;
|
||||
}
|
||||
@@ -230,6 +233,9 @@ class ControlledAgentClient implements AgentClient {
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
if (config.internal) {
|
||||
return this.internalClient.createSession(config, launchContext, options);
|
||||
}
|
||||
this.creations++;
|
||||
this.createdConfigs.push({ ...config });
|
||||
this.creationObserved.resolve();
|
||||
@@ -242,6 +248,9 @@ class ControlledAgentClient implements AgentClient {
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
if (overrides?.internal) {
|
||||
return this.internalClient.resumeSession(handle, overrides, launchContext);
|
||||
}
|
||||
this.resumes++;
|
||||
return this.client.resumeSession(handle, overrides, launchContext);
|
||||
}
|
||||
@@ -438,6 +447,7 @@ export class HubRelationshipHarness {
|
||||
this.providerPrompts.push(prompt);
|
||||
},
|
||||
}).codex,
|
||||
createTestAgentClients({ supportsMcpServers: mcpEnabled }).codex,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -82,8 +82,13 @@ export function createNoopWorkspaceGitService(
|
||||
workingTreeWatchSetupInFlightCount: 0,
|
||||
workspaceRefreshInFlightCount: 0,
|
||||
workspaceRefreshQueuedCount: 0,
|
||||
workspaceRefreshAdmissionActiveCount: 0,
|
||||
workspaceRefreshAdmissionPendingCount: 0,
|
||||
workspaceObservationSetupAdmissionActiveCount: 0,
|
||||
workspaceObservationSetupAdmissionPendingCount: 0,
|
||||
fetchInFlightCount: 0,
|
||||
snapshotUpdatedListenerCount: 0,
|
||||
watcherErrorCallbackCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
...overrides,
|
||||
|
||||
@@ -246,8 +246,13 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
|
||||
workingTreeWatchSetupInFlightCount: 0,
|
||||
workspaceRefreshInFlightCount: 0,
|
||||
workspaceRefreshQueuedCount: 0,
|
||||
workspaceRefreshAdmissionActiveCount: 0,
|
||||
workspaceRefreshAdmissionPendingCount: 0,
|
||||
workspaceObservationSetupAdmissionActiveCount: 0,
|
||||
workspaceObservationSetupAdmissionPendingCount: 0,
|
||||
fetchInFlightCount: 0,
|
||||
snapshotUpdatedListenerCount: 0,
|
||||
watcherErrorCallbackCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
diffWorkspaceGitRefs,
|
||||
diffWorkspaceGitRemoteRefs,
|
||||
parseWorkspaceGitRefs,
|
||||
} from "./workspace-git-fetch.js";
|
||||
|
||||
describe("workspace Git remote refs", () => {
|
||||
test("parses refs for semantic before/after comparison", () => {
|
||||
expect(
|
||||
parseWorkspaceGitRefs(
|
||||
[
|
||||
"refs/remotes/origin/main\0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"refs/remotes/upstream/topic\0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"refs/heads/main\0cccccccccccccccccccccccccccccccccccccccc",
|
||||
].join("\n"),
|
||||
),
|
||||
).toEqual(
|
||||
new Map([
|
||||
["refs/remotes/origin/main", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
|
||||
["refs/remotes/upstream/topic", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
|
||||
["refs/heads/main", "cccccccccccccccccccccccccccccccccccccccc"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("distinguishes remote changes from concurrent local ref changes", () => {
|
||||
expect(
|
||||
diffWorkspaceGitRefs(
|
||||
new Map([
|
||||
["refs/remotes/origin/main", "aaaaaaaa"],
|
||||
["refs/heads/topic", "bbbbbbbb"],
|
||||
]),
|
||||
new Map([
|
||||
["refs/remotes/origin/main", "cccccccc"],
|
||||
["refs/heads/topic", "dddddddd"],
|
||||
]),
|
||||
),
|
||||
).toEqual({
|
||||
remoteChanges: [
|
||||
{
|
||||
kind: "moved",
|
||||
ref: "origin/main",
|
||||
beforeOid: "aaaaaaaa",
|
||||
afterOid: "cccccccc",
|
||||
},
|
||||
],
|
||||
nonRemoteRefsChanged: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("reports added, changed, and deleted remote refs", () => {
|
||||
expect(
|
||||
diffWorkspaceGitRemoteRefs(
|
||||
new Map([
|
||||
["origin/main", "aaaaaaaa"],
|
||||
["origin/deleted", "bbbbbbbb"],
|
||||
["origin/unchanged", "cccccccc"],
|
||||
]),
|
||||
new Map([
|
||||
["origin/main", "dddddddd"],
|
||||
["origin/added", "eeeeeeee"],
|
||||
["origin/unchanged", "cccccccc"],
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "moved",
|
||||
ref: "origin/main",
|
||||
beforeOid: "aaaaaaaa",
|
||||
afterOid: "dddddddd",
|
||||
},
|
||||
{ kind: "added", ref: "origin/added", afterOid: "eeeeeeee" },
|
||||
{ kind: "deleted", ref: "origin/deleted", beforeOid: "bbbbbbbb" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
|
||||
export interface WorkspaceGitFetchResult {
|
||||
changes: WorkspaceGitRemoteRefChange[] | null;
|
||||
nonRemoteRefsChanged?: boolean;
|
||||
remoteRefs?: ReadonlySet<string>;
|
||||
error: unknown | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceGitFetchObserver {
|
||||
onRefSnapshot(phase: "before" | "after"): void;
|
||||
}
|
||||
|
||||
export type WorkspaceGitRemoteRefChange =
|
||||
| { kind: "moved"; ref: string; beforeOid: string; afterOid: string }
|
||||
| { kind: "added"; ref: string; afterOid: string }
|
||||
| { kind: "deleted"; ref: string; beforeOid: string };
|
||||
|
||||
export async function fetchWorkspaceGitRemote(
|
||||
cwd: string,
|
||||
observer: WorkspaceGitFetchObserver,
|
||||
): Promise<WorkspaceGitFetchResult> {
|
||||
let before: Map<string, string> | null = null;
|
||||
let after: Map<string, string> | null = null;
|
||||
let error: unknown | null = null;
|
||||
try {
|
||||
before = await readWorkspaceGitRefs(cwd);
|
||||
observer.onRefSnapshot("before");
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
try {
|
||||
await runGitCommand(["fetch", "origin", "--prune"], {
|
||||
cwd,
|
||||
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch (caught) {
|
||||
error ??= caught;
|
||||
}
|
||||
try {
|
||||
after = await readWorkspaceGitRefs(cwd);
|
||||
} catch (caught) {
|
||||
error ??= caught;
|
||||
}
|
||||
if (!before || !after) {
|
||||
return { changes: null, error };
|
||||
}
|
||||
const diff = diffWorkspaceGitRefs(before, after);
|
||||
const result = {
|
||||
changes: diff.remoteChanges,
|
||||
nonRemoteRefsChanged: diff.nonRemoteRefsChanged,
|
||||
remoteRefs: collectWorkspaceGitRemoteRefs(after),
|
||||
error,
|
||||
} satisfies WorkspaceGitFetchResult;
|
||||
observer.onRefSnapshot("after");
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectWorkspaceGitRemoteRefs(refs: ReadonlyMap<string, string>): Set<string> {
|
||||
const remotePrefix = "refs/remotes/";
|
||||
return new Set(
|
||||
[...refs.keys()]
|
||||
.filter((ref) => ref.startsWith(remotePrefix))
|
||||
.map((ref) => ref.slice(remotePrefix.length)),
|
||||
);
|
||||
}
|
||||
|
||||
async function readWorkspaceGitRefs(cwd: string): Promise<Map<string, string>> {
|
||||
const { stdout } = await runGitCommand(["for-each-ref", "--format=%(refname)%00%(objectname)"], {
|
||||
cwd,
|
||||
});
|
||||
return parseWorkspaceGitRefs(stdout);
|
||||
}
|
||||
|
||||
export function parseWorkspaceGitRefs(stdout: string): Map<string, string> {
|
||||
const refs = new Map<string, string>();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const [ref, objectId] = line.split("\0");
|
||||
if (ref?.startsWith("refs/") && objectId) {
|
||||
refs.set(ref, objectId);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
export interface WorkspaceGitRefDiff {
|
||||
remoteChanges: WorkspaceGitRemoteRefChange[];
|
||||
nonRemoteRefsChanged: boolean;
|
||||
}
|
||||
|
||||
export function diffWorkspaceGitRefs(
|
||||
before: ReadonlyMap<string, string>,
|
||||
after: ReadonlyMap<string, string>,
|
||||
): WorkspaceGitRefDiff {
|
||||
const remotePrefix = "refs/remotes/";
|
||||
const beforeRemote = new Map<string, string>();
|
||||
const afterRemote = new Map<string, string>();
|
||||
let nonRemoteRefsChanged = false;
|
||||
for (const [ref, objectId] of before) {
|
||||
if (ref.startsWith(remotePrefix)) {
|
||||
beforeRemote.set(ref.slice(remotePrefix.length), objectId);
|
||||
} else if (after.get(ref) !== objectId) {
|
||||
nonRemoteRefsChanged = true;
|
||||
}
|
||||
}
|
||||
for (const [ref, objectId] of after) {
|
||||
if (ref.startsWith(remotePrefix)) {
|
||||
afterRemote.set(ref.slice(remotePrefix.length), objectId);
|
||||
} else if (!before.has(ref)) {
|
||||
nonRemoteRefsChanged = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
remoteChanges: diffWorkspaceGitRemoteRefs(beforeRemote, afterRemote),
|
||||
nonRemoteRefsChanged,
|
||||
};
|
||||
}
|
||||
|
||||
export function diffWorkspaceGitRemoteRefs(
|
||||
before: ReadonlyMap<string, string>,
|
||||
after: ReadonlyMap<string, string>,
|
||||
): WorkspaceGitRemoteRefChange[] {
|
||||
const changes: WorkspaceGitRemoteRefChange[] = [];
|
||||
for (const [ref, objectId] of after) {
|
||||
const beforeOid = before.get(ref);
|
||||
if (beforeOid === undefined) {
|
||||
changes.push({ kind: "added", ref, afterOid: objectId });
|
||||
} else if (beforeOid !== objectId) {
|
||||
changes.push({ kind: "moved", ref, beforeOid, afterOid: objectId });
|
||||
}
|
||||
}
|
||||
for (const [ref, beforeOid] of before) {
|
||||
if (!after.has(ref)) {
|
||||
changes.push({ kind: "deleted", ref, beforeOid });
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import pino from "pino";
|
||||
import { afterAll, beforeAll, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getGitCommandMetrics,
|
||||
startGitCommandMetrics,
|
||||
stopGitCommandMetrics,
|
||||
waitForGitCommandMetricsIdle,
|
||||
} from "../utils/run-git-command.js";
|
||||
import { fetchWorkspaceGitRemote } from "./workspace-git-fetch.js";
|
||||
import {
|
||||
WorkspaceGitServiceImpl,
|
||||
getWorkspaceGitSelfHealPhaseMs,
|
||||
} from "./workspace-git-service.js";
|
||||
|
||||
const SIBLING_COUNT = 59;
|
||||
const REMOTE_REF_COUNT = 19_500;
|
||||
const cleanupPaths: string[] = [];
|
||||
let fixture: ReturnType<typeof seedFetchFixture>;
|
||||
|
||||
beforeAll(() => {
|
||||
fixture = seedFetchFixture();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
for (const path of cleanupPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
function git(cwd: string, args: string[], input?: string): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
input,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
},
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function gitAsync(cwd: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
"git",
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
},
|
||||
},
|
||||
(error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
function hasSubmittedGitOperation(operation: string): boolean {
|
||||
return getGitCommandMetrics().submissions.some((command) => command.args[0] === operation);
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
description: string,
|
||||
condition: () => boolean,
|
||||
timeoutMs = 120_000,
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
throw new Error(`Timed out waiting for ${description}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
||||
function seedFetchFixture(): {
|
||||
originRoot: string;
|
||||
paseoHome: string;
|
||||
repoRoot: string;
|
||||
worktrees: string[];
|
||||
} {
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), "paseo-noop-fetch-"));
|
||||
cleanupPaths.push(fixtureRoot);
|
||||
const repoRoot = join(fixtureRoot, "repo");
|
||||
const originRoot = join(fixtureRoot, "origin.git");
|
||||
const paseoHome = join(fixtureRoot, "paseo-home");
|
||||
const worktreesRoot = join(fixtureRoot, "worktrees");
|
||||
mkdirSync(repoRoot, { recursive: true });
|
||||
mkdirSync(paseoHome, { recursive: true });
|
||||
mkdirSync(worktreesRoot, { recursive: true });
|
||||
|
||||
git(repoRoot, ["init", "-b", "main"]);
|
||||
git(repoRoot, ["config", "user.email", "repro@example.com"]);
|
||||
git(repoRoot, ["config", "user.name", "Repro"]);
|
||||
writeFileSync(join(repoRoot, "README.md"), "no-op fetch repro\n");
|
||||
git(repoRoot, ["add", "README.md"]);
|
||||
git(repoRoot, ["commit", "-m", "initial"]);
|
||||
|
||||
git(fixtureRoot, ["init", "--bare", originRoot]);
|
||||
git(repoRoot, ["remote", "add", "origin", originRoot]);
|
||||
git(repoRoot, ["push", "--quiet", "-u", "origin", "main"]);
|
||||
|
||||
const head = git(repoRoot, ["rev-parse", "HEAD"]);
|
||||
const remoteRefUpdates = Array.from(
|
||||
{ length: REMOTE_REF_COUNT },
|
||||
(_, index) => `update refs/heads/load/${index.toString().padStart(5, "0")} ${head}`,
|
||||
).join("\n");
|
||||
git(originRoot, ["update-ref", "--stdin"], `${remoteRefUpdates}\n`);
|
||||
git(originRoot, ["pack-refs", "--all", "--prune"]);
|
||||
git(repoRoot, ["fetch", "--quiet", "origin", "--prune"]);
|
||||
git(repoRoot, ["pack-refs", "--all", "--prune"]);
|
||||
|
||||
const worktrees = Array.from({ length: SIBLING_COUNT }, (_, index) => {
|
||||
let suffix = 0;
|
||||
let branch = `sibling-${index}-${suffix}`;
|
||||
let cwd = join(worktreesRoot, branch);
|
||||
while (getWorkspaceGitSelfHealPhaseMs(cwd) < 50_000) {
|
||||
suffix += 1;
|
||||
branch = `sibling-${index}-${suffix}`;
|
||||
cwd = join(worktreesRoot, branch);
|
||||
}
|
||||
git(repoRoot, ["worktree", "add", "--quiet", "-b", branch, cwd, "HEAD"]);
|
||||
return cwd;
|
||||
});
|
||||
|
||||
return { originRoot, paseoHome, repoRoot, worktrees };
|
||||
}
|
||||
|
||||
function advanceOriginRef(originRoot: string, ref: string): void {
|
||||
git(originRoot, ["config", "user.email", "repro@example.com"]);
|
||||
git(originRoot, ["config", "user.name", "Repro"]);
|
||||
const previous = git(originRoot, ["rev-parse", ref]);
|
||||
const tree = git(originRoot, ["show", "-s", "--format=%T", previous]);
|
||||
const next = git(originRoot, ["commit-tree", tree, "-p", previous], `advance ${ref}\n`);
|
||||
git(originRoot, ["update-ref", ref, next, previous]);
|
||||
}
|
||||
|
||||
function advanceOriginMain(originRoot: string): void {
|
||||
advanceOriginRef(originRoot, "refs/heads/main");
|
||||
}
|
||||
|
||||
async function measureFetchScenario(
|
||||
name: string,
|
||||
beforeFetch: () => void,
|
||||
duringFetch?: () => Promise<void>,
|
||||
quietMs = 1_500,
|
||||
): Promise<{
|
||||
fetchCount: number;
|
||||
gitCommands: number;
|
||||
nonRemoteRefsChanged: boolean | undefined;
|
||||
operations: Record<string, number>;
|
||||
snapshotUpdates: number;
|
||||
}> {
|
||||
const fetchStarted = createDeferred<void>();
|
||||
const releaseFetch = createDeferred<void>();
|
||||
let fetchCount = 0;
|
||||
let fetchChangedNonRemoteRefs: boolean | undefined;
|
||||
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ level: "silent" }),
|
||||
paseoHome: fixture.paseoHome,
|
||||
deps: {
|
||||
runGitFetch: async (cwd, observer) => {
|
||||
fetchStarted.resolve();
|
||||
await releaseFetch.promise;
|
||||
const result = await fetchWorkspaceGitRemote(cwd, observer);
|
||||
fetchChangedNonRemoteRefs = result.nonRemoteRefsChanged;
|
||||
fetchCount += 1;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
});
|
||||
const snapshotCounts = new Map(fixture.worktrees.map((cwd) => [cwd, 0]));
|
||||
const subscriptions = fixture.worktrees.map((cwd) =>
|
||||
service.registerWorkspace({ cwd }, () => {
|
||||
snapshotCounts.set(cwd, (snapshotCounts.get(cwd) ?? 0) + 1);
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await fetchStarted.promise;
|
||||
await waitForCondition(
|
||||
"all sibling workspace snapshots to warm before the measured fetch runs",
|
||||
() =>
|
||||
[...snapshotCounts.values()].every((count) => count >= 1) &&
|
||||
service.getMetrics().repositoryWorkspaceLinkCount === SIBLING_COUNT &&
|
||||
service.getMetrics().workspaceRefreshInFlightCount === 0 &&
|
||||
service.getMetrics().workspaceRefreshQueuedCount === 0,
|
||||
);
|
||||
|
||||
beforeFetch();
|
||||
|
||||
const snapshotBaseline = new Map(snapshotCounts);
|
||||
const fetchCycleStartedAt = Date.now();
|
||||
startGitCommandMetrics();
|
||||
releaseFetch.resolve();
|
||||
await duringFetch?.();
|
||||
await waitForGitCommandMetricsIdle({ quietMs, timeoutMs: 120_000 });
|
||||
const postFetch = stopGitCommandMetrics();
|
||||
const snapshotUpdatesAfterFetch = [...snapshotCounts].reduce(
|
||||
(total, [cwd, count]) => total + Math.max(0, count - (snapshotBaseline.get(cwd) ?? 0)),
|
||||
0,
|
||||
);
|
||||
|
||||
const operations = Object.fromEntries(
|
||||
[...Map.groupBy(postFetch.submissions, (command) => command.args[0] ?? "").entries()]
|
||||
.map(([operation, commands]) => [operation, commands.length] as const)
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
console.info(
|
||||
"[workspace-git-fetch]",
|
||||
JSON.stringify({
|
||||
scenario: name,
|
||||
siblingCount: SIBLING_COUNT,
|
||||
remoteRefCount: REMOTE_REF_COUNT,
|
||||
fetchCount,
|
||||
durationMs: Date.now() - fetchCycleStartedAt,
|
||||
gitCommands: postFetch.submitted,
|
||||
maxConcurrentGitCommands: postFetch.maxConcurrent,
|
||||
operations,
|
||||
snapshotUpdates: snapshotUpdatesAfterFetch,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
fetchCount,
|
||||
gitCommands: postFetch.submitted,
|
||||
nonRemoteRefsChanged: fetchChangedNonRemoteRefs,
|
||||
operations,
|
||||
snapshotUpdates: snapshotUpdatesAfterFetch,
|
||||
};
|
||||
} finally {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
service.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function measureExternalFetchScenario(
|
||||
name: string,
|
||||
beforeFetch: () => void,
|
||||
): Promise<{
|
||||
gitCommands: number;
|
||||
operations: Record<string, number>;
|
||||
snapshotUpdates: number;
|
||||
}> {
|
||||
const initialFetchCompleted = createDeferred<void>();
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ level: "silent" }),
|
||||
paseoHome: fixture.paseoHome,
|
||||
deps: {
|
||||
runGitFetch: async (cwd, observer) => {
|
||||
const result = await fetchWorkspaceGitRemote(cwd, observer);
|
||||
initialFetchCompleted.resolve();
|
||||
return result;
|
||||
},
|
||||
},
|
||||
});
|
||||
const snapshotCounts = new Map(fixture.worktrees.map((cwd) => [cwd, 0]));
|
||||
const subscriptions = fixture.worktrees.map((cwd) =>
|
||||
service.registerWorkspace({ cwd }, () => {
|
||||
snapshotCounts.set(cwd, (snapshotCounts.get(cwd) ?? 0) + 1);
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await initialFetchCompleted.promise;
|
||||
await waitForCondition(
|
||||
"all sibling workspace snapshots to warm before the external fetch runs",
|
||||
() =>
|
||||
[...snapshotCounts.values()].every((count) => count >= 1) &&
|
||||
service.getMetrics().repositoryWorkspaceLinkCount === SIBLING_COUNT &&
|
||||
service.getMetrics().workspaceRefreshInFlightCount === 0 &&
|
||||
service.getMetrics().workspaceRefreshQueuedCount === 0,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 6_000));
|
||||
|
||||
const snapshotBaseline = new Map(snapshotCounts);
|
||||
beforeFetch();
|
||||
const fetchCycleStartedAt = Date.now();
|
||||
startGitCommandMetrics();
|
||||
await gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]);
|
||||
await waitForGitCommandMetricsIdle({ quietMs: 6_000, timeoutMs: 120_000 });
|
||||
const postFetch = stopGitCommandMetrics();
|
||||
const snapshotUpdates = [...snapshotCounts].reduce(
|
||||
(total, [cwd, count]) => total + Math.max(0, count - (snapshotBaseline.get(cwd) ?? 0)),
|
||||
0,
|
||||
);
|
||||
const operations = Object.fromEntries(
|
||||
[...Map.groupBy(postFetch.submissions, (command) => command.args[0] ?? "").entries()]
|
||||
.map(([operation, commands]) => [operation, commands.length] as const)
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
console.info(
|
||||
"[workspace-git-fetch]",
|
||||
JSON.stringify({
|
||||
scenario: name,
|
||||
siblingCount: SIBLING_COUNT,
|
||||
remoteRefCount: REMOTE_REF_COUNT,
|
||||
durationMs: Date.now() - fetchCycleStartedAt,
|
||||
gitCommands: postFetch.submitted,
|
||||
maxConcurrentGitCommands: postFetch.maxConcurrent,
|
||||
operations,
|
||||
snapshotUpdates,
|
||||
}),
|
||||
);
|
||||
return { gitCommands: postFetch.submitted, operations, snapshotUpdates };
|
||||
} finally {
|
||||
for (const subscription of subscriptions) subscription.unsubscribe();
|
||||
service.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
test("a no-op fetch does not refresh sibling worktrees", async () => {
|
||||
expect(await measureFetchScenario("a no-op fetch", () => {})).toEqual({
|
||||
fetchCount: 1,
|
||||
gitCommands: 3,
|
||||
nonRemoteRefsChanged: false,
|
||||
operations: { fetch: 1, "for-each-ref": 2 },
|
||||
snapshotUpdates: 0,
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
test("an origin/main update narrowly refreshes 59 sibling worktrees", async () => {
|
||||
expect(
|
||||
await measureFetchScenario("an origin/main update", () => {
|
||||
advanceOriginMain(fixture.originRoot);
|
||||
}),
|
||||
).toEqual({
|
||||
fetchCount: 1,
|
||||
gitCommands: 239,
|
||||
nonRemoteRefsChanged: false,
|
||||
operations: {
|
||||
diff: 59,
|
||||
fetch: 1,
|
||||
"for-each-ref": 2,
|
||||
"ls-files": 59,
|
||||
"merge-base": 59,
|
||||
"rev-list": 59,
|
||||
},
|
||||
snapshotUpdates: 59,
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
test("an overlapping external no-op fetch stays idle", async () => {
|
||||
const result = await measureFetchScenario(
|
||||
"an overlapping external no-op fetch",
|
||||
() => {},
|
||||
() => gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]),
|
||||
6_000,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
fetchCount: 1,
|
||||
gitCommands: 3,
|
||||
nonRemoteRefsChanged: false,
|
||||
operations: { fetch: 1, "for-each-ref": 2 },
|
||||
snapshotUpdates: 0,
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
test("an overlapping external fetch of one main update stays narrow", async () => {
|
||||
const result = await measureFetchScenario(
|
||||
"an overlapping external fetch of one main update",
|
||||
() => {
|
||||
advanceOriginMain(fixture.originRoot);
|
||||
},
|
||||
() => gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]),
|
||||
6_000,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
fetchCount: 1,
|
||||
gitCommands: 239,
|
||||
nonRemoteRefsChanged: false,
|
||||
operations: {
|
||||
diff: 59,
|
||||
fetch: 1,
|
||||
"for-each-ref": 2,
|
||||
"ls-files": 59,
|
||||
"merge-base": 59,
|
||||
"rev-list": 59,
|
||||
},
|
||||
snapshotUpdates: 59,
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
test("an overlapping external fetch of an unrelated branch stays idle", async () => {
|
||||
const result = await measureFetchScenario(
|
||||
"an overlapping external fetch of an unrelated branch",
|
||||
() => {
|
||||
advanceOriginRef(fixture.originRoot, "refs/heads/load/00000");
|
||||
},
|
||||
() => gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]),
|
||||
6_000,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
fetchCount: 1,
|
||||
gitCommands: 3,
|
||||
nonRemoteRefsChanged: false,
|
||||
operations: { fetch: 1, "for-each-ref": 2 },
|
||||
snapshotUpdates: 0,
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
test("a second same-ref fetch during calculation stays narrow", async () => {
|
||||
const result = await measureFetchScenario(
|
||||
"a second same-ref fetch during calculation",
|
||||
() => {
|
||||
advanceOriginMain(fixture.originRoot);
|
||||
},
|
||||
async () => {
|
||||
await waitForCondition("the first narrow calculation to start", () =>
|
||||
hasSubmittedGitOperation("rev-list"),
|
||||
);
|
||||
advanceOriginMain(fixture.originRoot);
|
||||
await gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]);
|
||||
},
|
||||
6_000,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
fetchCount: 1,
|
||||
gitCommands: 475,
|
||||
nonRemoteRefsChanged: false,
|
||||
operations: {
|
||||
diff: 118,
|
||||
fetch: 1,
|
||||
"for-each-ref": 2,
|
||||
"ls-files": 118,
|
||||
"merge-base": 118,
|
||||
"rev-list": 118,
|
||||
},
|
||||
snapshotUpdates: 118,
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
test("a standalone external fetch of an unrelated packed branch stays idle", async () => {
|
||||
const result = await measureExternalFetchScenario(
|
||||
"a standalone external fetch of an unrelated packed branch",
|
||||
() => {
|
||||
advanceOriginRef(fixture.originRoot, "refs/heads/load/00001");
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ gitCommands: 0, operations: {}, snapshotUpdates: 0 });
|
||||
}, 240_000);
|
||||
@@ -103,6 +103,7 @@ function createLogger(): pino.Logger {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
return logger as unknown as pino.Logger;
|
||||
@@ -122,9 +123,25 @@ function getCalledCwds(mock: ReturnType<typeof vi.fn>): string[] {
|
||||
return mock.mock.calls.map(([cwd]) => cwd as string);
|
||||
}
|
||||
|
||||
function getWatcherRecordsForDirectory(
|
||||
watcher: ReturnType<typeof createWatcherHarness>,
|
||||
directory: string,
|
||||
): WatchRecord[] {
|
||||
return watcher.records.filter((record) => record.directory === directory);
|
||||
}
|
||||
|
||||
function getWatcherSubscribeCallCount(
|
||||
watcher: ReturnType<typeof createWatcherHarness>,
|
||||
directory: string,
|
||||
): number {
|
||||
return watcher.subscribe.mock.calls.filter(([calledDirectory]) => calledDirectory === directory)
|
||||
.length;
|
||||
}
|
||||
|
||||
function createService(
|
||||
watcher: ReturnType<typeof createWatcherHarness>,
|
||||
overrides?: Record<string, unknown>,
|
||||
logger: pino.Logger = createLogger(),
|
||||
) {
|
||||
const defaultGetCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const defaultGetCheckoutShortstat = vi.fn(async () => null);
|
||||
@@ -135,11 +152,15 @@ function createService(
|
||||
(overrides?.getCheckoutShortstat as typeof defaultGetCheckoutShortstat | undefined) ??
|
||||
defaultGetCheckoutShortstat;
|
||||
return new WorkspaceGitServiceImpl({
|
||||
logger: createLogger(),
|
||||
logger,
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
deps: {
|
||||
subscribe: watcher.subscribe,
|
||||
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutFacts(cwd)),
|
||||
getCheckoutRefDerivedState: vi.fn(async (_cwd, facts, current) => ({
|
||||
...current,
|
||||
upstreamStatus: facts.upstreamStatus,
|
||||
})),
|
||||
getCheckoutStatus,
|
||||
getCheckoutShortstat,
|
||||
getCheckoutWorktreeState: vi.fn(async (cwd: string) => {
|
||||
@@ -424,6 +445,441 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("a loose remote-ref watcher echo during fetch coalesces into the narrow refresh", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const releaseFetch = createDeferred<void>();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||
...createCheckoutFacts(cwd),
|
||||
currentBranch: "feature",
|
||||
remoteUrl: "https://example.com/repo.git",
|
||||
resolvedBaseRef: "main",
|
||||
comparisonBaseRef: "origin/main",
|
||||
}));
|
||||
const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({
|
||||
...current,
|
||||
upstreamStatus: facts.upstreamStatus,
|
||||
}));
|
||||
const service = createService(watcher, {
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutRefDerivedState,
|
||||
getCheckoutStatus: vi.fn(async (cwd: string) =>
|
||||
createCheckoutStatus(cwd, {
|
||||
currentBranch: "feature",
|
||||
baseRef: "main",
|
||||
hasRemote: true,
|
||||
remoteUrl: "https://example.com/repo.git",
|
||||
}),
|
||||
),
|
||||
hasOriginRemote: vi.fn(async () => true),
|
||||
runGitFetch: vi.fn(async (_cwd, observer) => {
|
||||
observer?.onRefSnapshot("before");
|
||||
await releaseFetch.promise;
|
||||
observer?.onRefSnapshot("after");
|
||||
return {
|
||||
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
|
||||
error: null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
const listener = vi.fn();
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().fetchInFlightCount).toBe(1);
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
listener.mockClear();
|
||||
|
||||
watcher.records
|
||||
.find((record) => record.directory === GIT_DIR)
|
||||
?.callback(null, [
|
||||
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "create" },
|
||||
]);
|
||||
releaseFetch.resolve();
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("a packed-refs event covered by the fetch snapshot uses the exact ref delta", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const fetchWindowOpen = createDeferred<void>();
|
||||
const releaseFetch = createDeferred<void>();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||
...createCheckoutFacts(cwd),
|
||||
currentBranch: "feature",
|
||||
remoteUrl: "https://example.com/repo.git",
|
||||
resolvedBaseRef: "main",
|
||||
comparisonBaseRef: "origin/main",
|
||||
}));
|
||||
const getCheckoutRefDerivedState = vi.fn();
|
||||
const service = createService(watcher, {
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutRefDerivedState,
|
||||
hasOriginRemote: vi.fn(async () => true),
|
||||
runGitFetch: vi.fn(async (_cwd, observer) => {
|
||||
observer.onRefSnapshot("before");
|
||||
fetchWindowOpen.resolve();
|
||||
await releaseFetch.promise;
|
||||
observer.onRefSnapshot("after");
|
||||
return {
|
||||
changes: [
|
||||
{
|
||||
kind: "moved" as const,
|
||||
ref: "origin/unrelated",
|
||||
beforeOid: "a",
|
||||
afterOid: "b",
|
||||
},
|
||||
],
|
||||
nonRemoteRefsChanged: false,
|
||||
remoteRefs: new Set(["origin/main", "origin/unrelated"]),
|
||||
error: null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await fetchWindowOpen.promise;
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
watcher.records
|
||||
.find((record) => record.directory === GIT_DIR)
|
||||
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
|
||||
releaseFetch.resolve();
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test.each(["before", "after"] as const)(
|
||||
"a packed-refs event %s the fetch snapshot remains conservative",
|
||||
async (phase) => {
|
||||
const watcher = createWatcherHarness();
|
||||
const afterSnapshot = createDeferred<void>();
|
||||
const releaseFetch = createDeferred<void>();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||
...createCheckoutFacts(cwd),
|
||||
remoteUrl: "https://example.com/repo.git",
|
||||
}));
|
||||
const runGitFetch = vi.fn(async (_cwd, observer) => {
|
||||
if (phase === "before") {
|
||||
await releaseFetch.promise;
|
||||
observer.onRefSnapshot("before");
|
||||
observer.onRefSnapshot("after");
|
||||
} else {
|
||||
observer.onRefSnapshot("before");
|
||||
observer.onRefSnapshot("after");
|
||||
afterSnapshot.resolve();
|
||||
await releaseFetch.promise;
|
||||
}
|
||||
return {
|
||||
changes: [],
|
||||
nonRemoteRefsChanged: false,
|
||||
remoteRefs: new Set(["origin/main"]),
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
const service = createService(watcher, {
|
||||
getCheckoutSnapshotFacts,
|
||||
hasOriginRemote: vi.fn(async () => true),
|
||||
runGitFetch,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await vi.waitFor(() => {
|
||||
expect(runGitFetch).toHaveBeenCalledTimes(1);
|
||||
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1);
|
||||
});
|
||||
if (phase === "after") await afterSnapshot.promise;
|
||||
|
||||
watcher.records
|
||||
.find((record) => record.directory === GIT_DIR)
|
||||
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
|
||||
releaseFetch.resolve();
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().fetchInFlightCount).toBe(0);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test("a post-snapshot delete overrides the fetch's moved-ref classification", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const afterSnapshot = createDeferred<void>();
|
||||
const releaseFetch = createDeferred<void>();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||
...createCheckoutFacts(cwd),
|
||||
remoteUrl: "https://example.com/repo.git",
|
||||
}));
|
||||
const getCheckoutRefDerivedState = vi.fn();
|
||||
const service = createService(watcher, {
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutRefDerivedState,
|
||||
hasOriginRemote: vi.fn(async () => true),
|
||||
runGitFetch: vi.fn(async (_cwd, observer) => {
|
||||
observer.onRefSnapshot("before");
|
||||
observer.onRefSnapshot("after");
|
||||
afterSnapshot.resolve();
|
||||
await releaseFetch.promise;
|
||||
return {
|
||||
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
|
||||
nonRemoteRefsChanged: false,
|
||||
remoteRefs: new Set(["origin/main"]),
|
||||
error: null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await afterSnapshot.promise;
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
watcher.records
|
||||
.find((record) => record.directory === GIT_DIR)
|
||||
?.callback(null, [
|
||||
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "delete" },
|
||||
]);
|
||||
releaseFetch.resolve();
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().fetchInFlightCount).toBe(0);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("known packed remote refs materialize narrowly while namespace directories are ignored", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||
...createCheckoutFacts(cwd),
|
||||
currentBranch: "feature",
|
||||
remoteUrl: "https://example.com/repo.git",
|
||||
resolvedBaseRef: "main",
|
||||
comparisonBaseRef: "origin/main",
|
||||
}));
|
||||
const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({
|
||||
...current,
|
||||
upstreamStatus: facts.upstreamStatus,
|
||||
}));
|
||||
const service = createService(watcher, {
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutRefDerivedState,
|
||||
hasOriginRemote: vi.fn(async () => true),
|
||||
runGitFetch: vi.fn(async (_cwd, observer) => {
|
||||
observer.onRefSnapshot("before");
|
||||
observer.onRefSnapshot("after");
|
||||
return {
|
||||
changes: [],
|
||||
nonRemoteRefsChanged: false,
|
||||
remoteRefs: new Set(["origin/main", "origin/load/00001"]),
|
||||
error: null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().fetchInFlightCount).toBe(0);
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const repoWatcher = watcher.records.find((record) => record.directory === GIT_DIR);
|
||||
repoWatcher?.callback(null, [
|
||||
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "load"), type: "create" },
|
||||
{
|
||||
path: path.join(GIT_DIR, "refs", "remotes", "origin", "load", "00001"),
|
||||
type: "create",
|
||||
},
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
|
||||
|
||||
repoWatcher?.callback(null, [
|
||||
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "create" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("a second remote-ref move during a narrow refresh queues a final calculation", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const firstRefRefresh = createDeferred<CheckoutStatusGit>();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||
...createCheckoutFacts(cwd),
|
||||
currentBranch: "feature",
|
||||
remoteUrl: "https://example.com/repo.git",
|
||||
resolvedBaseRef: "main",
|
||||
comparisonBaseRef: "origin/main",
|
||||
}));
|
||||
const getCheckoutRefDerivedState = vi
|
||||
.fn<
|
||||
(
|
||||
cwd: string,
|
||||
facts: CheckoutSnapshotFacts,
|
||||
current: CheckoutStatusGit,
|
||||
) => Promise<CheckoutStatusGit>
|
||||
>()
|
||||
.mockImplementationOnce(() => firstRefRefresh.promise)
|
||||
.mockImplementation(async (_cwd, facts, current) => ({
|
||||
...current,
|
||||
upstreamStatus: facts.upstreamStatus,
|
||||
}));
|
||||
const service = createService(watcher, {
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutRefDerivedState,
|
||||
getCheckoutStatus: vi.fn(async (cwd: string) =>
|
||||
createCheckoutStatus(cwd, {
|
||||
currentBranch: "feature",
|
||||
baseRef: "main",
|
||||
hasRemote: true,
|
||||
remoteUrl: "https://example.com/repo.git",
|
||||
}),
|
||||
),
|
||||
hasOriginRemote: vi.fn(async () => true),
|
||||
runGitFetch: vi.fn(async () => ({
|
||||
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
|
||||
error: null,
|
||||
})),
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
watcher.records
|
||||
.find((record) => record.directory === GIT_DIR)
|
||||
?.callback(null, [
|
||||
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(1);
|
||||
|
||||
firstRefRefresh.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(2);
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("an unmatched remote-ref event buffered after the fetch snapshot is refreshed", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const fetchSnapshotRead = createDeferred<void>();
|
||||
const releaseFetch = createDeferred<void>();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||
...createCheckoutFacts(cwd),
|
||||
currentBranch: "feature",
|
||||
remoteUrl: "https://example.com/repo.git",
|
||||
resolvedBaseRef: "main",
|
||||
comparisonBaseRef: "origin/main",
|
||||
}));
|
||||
const service = createService(watcher, {
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutStatus: vi.fn(async (cwd: string) =>
|
||||
createCheckoutStatus(cwd, {
|
||||
currentBranch: "feature",
|
||||
baseRef: "main",
|
||||
hasRemote: true,
|
||||
remoteUrl: "https://example.com/repo.git",
|
||||
}),
|
||||
),
|
||||
hasOriginRemote: vi.fn(async () => true),
|
||||
runGitFetch: vi.fn(async (_cwd, observer) => {
|
||||
observer?.onRefSnapshot("before");
|
||||
observer?.onRefSnapshot("after");
|
||||
fetchSnapshotRead.resolve();
|
||||
await releaseFetch.promise;
|
||||
return { changes: [], nonRemoteRefsChanged: false, error: null };
|
||||
}),
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await fetchSnapshotRead.promise;
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
watcher.records
|
||||
.find((record) => record.directory === GIT_DIR)
|
||||
?.callback(null, [
|
||||
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
|
||||
]);
|
||||
releaseFetch.resolve();
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("origin/main refreshes a main checkout without configured upstream", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||
...createCheckoutFacts(cwd),
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://example.com/repo.git",
|
||||
resolvedBaseRef: "main",
|
||||
comparisonBaseRef: null,
|
||||
branchRemoteName: null,
|
||||
branchMergeRef: null,
|
||||
upstreamStatus: null,
|
||||
}));
|
||||
const service = createService(watcher, { getCheckoutSnapshotFacts });
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
watcher.records
|
||||
.find((record) => record.directory === GIT_DIR)
|
||||
?.callback(null, [
|
||||
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("routes private worktree metadata to its owner and shared base refs to dependents", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createLinkedCheckoutFacts(cwd));
|
||||
@@ -518,7 +974,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("routes a newly created remote tracking ref to its configured workspace", async () => {
|
||||
test("a newly created remote tracking ref refreshes every repository workspace", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => {
|
||||
const facts = createLinkedCheckoutFacts(cwd);
|
||||
@@ -560,7 +1016,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A]);
|
||||
expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A, WORKTREE_B]);
|
||||
});
|
||||
|
||||
first.unsubscribe();
|
||||
@@ -814,7 +1270,10 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
test("ten worktrees share one repository metadata and fetch observer while retaining checkout state", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const fetch = createDeferred<void>();
|
||||
const runGitFetch = vi.fn(() => fetch.promise);
|
||||
const runGitFetch = vi.fn(async () => {
|
||||
await fetch.promise;
|
||||
return { changes: [], error: null };
|
||||
});
|
||||
const commonGitDir = path.resolve("/tmp/paseo-shared-repository.git");
|
||||
const worktrees = Array.from({ length: 10 }, (_, index) =>
|
||||
path.resolve(`/tmp/paseo-shared-worktree-${index}`),
|
||||
@@ -869,7 +1328,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(runGitFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(runGitFetch).toHaveBeenLastCalledWith(worktrees[1]);
|
||||
expect(runGitFetch).toHaveBeenLastCalledWith(worktrees[1], expect.anything());
|
||||
|
||||
for (const subscription of subscriptions.slice(1)) {
|
||||
subscription.unsubscribe();
|
||||
@@ -1003,13 +1462,14 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("watcher runtime error switches to scoped polling", async () => {
|
||||
test("watcher runtime error is abandoned, counted, and switches to scoped polling", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd));
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService(watcher, {
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutStatus,
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
@@ -1021,10 +1481,9 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
expect(checkoutWatcher).toBeDefined();
|
||||
|
||||
checkoutWatcher?.callback(new Error("watcher stopped"), []);
|
||||
await vi.waitFor(() => {
|
||||
expect(checkoutWatcher?.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(checkoutWatcher?.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(service.getMetrics().watcherErrorCallbackCount).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -1032,6 +1491,420 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(29_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(2);
|
||||
});
|
||||
const recoveredWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[1];
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
const statusCallsAfterRecovery = getCheckoutStatus.mock.calls.length;
|
||||
recoveredWatcher?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "recovered.txt"), type: "update" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterRecovery + 1);
|
||||
});
|
||||
const statusCallsAfterEvent = getCheckoutStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterEvent);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("setup-time watcher error defers recovery until subscribe settles", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const openedSubscription = createDeferred<{ unsubscribe: () => Promise<void> }>();
|
||||
const erroredUnsubscribe = vi.fn(async () => {});
|
||||
watcher.subscribe.mockImplementationOnce(async (_directory, callback) => {
|
||||
callback(new Error("watcher stopped during setup"), []);
|
||||
return openedSubscription.promise;
|
||||
});
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService(watcher, {
|
||||
getCheckoutStatus,
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
|
||||
});
|
||||
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await vi.waitFor(() => {
|
||||
expect(watcher.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(35_000);
|
||||
expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0);
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1);
|
||||
|
||||
openedSubscription.resolve({ unsubscribe: erroredUnsubscribe });
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const statusCallsAfterSetup = getCheckoutStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterSetup);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(24_000);
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(2);
|
||||
});
|
||||
expect(erroredUnsubscribe).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("watcher error during subscription setup is abandoned without native teardown", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const erroredUnsubscribe = vi.fn(async () => {});
|
||||
watcher.subscribe.mockImplementationOnce(async (_directory, callback) => {
|
||||
callback(new Error("watcher stopped during setup"), []);
|
||||
return { unsubscribe: erroredUnsubscribe };
|
||||
});
|
||||
const service = createService(watcher);
|
||||
|
||||
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn());
|
||||
|
||||
expect(erroredUnsubscribe).not.toHaveBeenCalled();
|
||||
expect(service.getMetrics().watcherErrorCallbackCount).toBe(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("repository watcher runtime error is abandoned, counted, and recovered", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService(watcher, {
|
||||
getCheckoutStatus,
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const repositoryWatcher = watcher.records.find((record) => record.directory === GIT_DIR);
|
||||
expect(repositoryWatcher).toBeDefined();
|
||||
|
||||
repositoryWatcher?.callback(new Error("repository watcher stopped"), []);
|
||||
|
||||
expect(repositoryWatcher?.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(service.getMetrics().watcherErrorCallbackCount).toBe(1);
|
||||
|
||||
const statusCallsBeforeReconciliation = getCheckoutStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsBeforeReconciliation + 1);
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(29_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(2);
|
||||
});
|
||||
const recoveredWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[1];
|
||||
const statusCallsAfterRecovery = getCheckoutStatus.mock.calls.length;
|
||||
recoveredWatcher?.callback(null, [{ path: path.join(GIT_DIR, "HEAD"), type: "update" }]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterRecovery + 1);
|
||||
});
|
||||
const statusCallsAfterEvent = getCheckoutStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterEvent);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("watcher recovery remains capped when recovered subscriptions emit events before failing", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService(watcher, {
|
||||
getCheckoutStatus,
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0];
|
||||
|
||||
checkoutWatcher?.callback(new Error("watcher stopped"), []);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(2);
|
||||
});
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[1]?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "recovered-1.txt"), type: "update" },
|
||||
]);
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[1]?.callback(
|
||||
new Error("recovered watcher stopped"),
|
||||
[],
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(3);
|
||||
});
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[2]?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "recovered-2.txt"), type: "update" },
|
||||
]);
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[2]?.callback(
|
||||
new Error("recovered watcher stopped again"),
|
||||
[],
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(4);
|
||||
});
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[3]?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "recovered-3.txt"), type: "update" },
|
||||
]);
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[3]?.callback(
|
||||
new Error("last recovered watcher stopped"),
|
||||
[],
|
||||
);
|
||||
|
||||
const statusCallsAtCap = getCheckoutStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(4);
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAtCap);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("repository watcher recovery remains capped after recovered subscriptions emit events", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService(watcher, {
|
||||
getCheckoutStatus,
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
getWatcherRecordsForDirectory(watcher, GIT_DIR)[0]?.callback(
|
||||
new Error("repository watcher stopped"),
|
||||
[],
|
||||
);
|
||||
|
||||
for (const [recoveryIndex, delayMs] of [30_000, 60_000, 120_000].entries()) {
|
||||
await vi.advanceTimersByTimeAsync(delayMs);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(recoveryIndex + 2);
|
||||
});
|
||||
const recoveredWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[recoveryIndex + 1];
|
||||
recoveredWatcher?.callback(null, [{ path: path.join(GIT_DIR, "HEAD"), type: "update" }]);
|
||||
recoveredWatcher?.callback(new Error("recovered repository watcher stopped"), []);
|
||||
}
|
||||
|
||||
const statusCallsAtCap = getCheckoutStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
expect(getWatcherSubscribeCallCount(watcher, GIT_DIR)).toBe(4);
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAtCap);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("non-Git discovery polling survives watcher recovery", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const getCheckoutSnapshotFacts = vi.fn(
|
||||
async (): Promise<CheckoutSnapshotFacts> => ({
|
||||
isGit: false,
|
||||
}),
|
||||
);
|
||||
const getCheckoutStatus = vi.fn(async () => ({ isGit: false }) as const);
|
||||
const runGitCommand = vi.fn(async () => {
|
||||
throw new Error("not a git repository");
|
||||
});
|
||||
const service = createService(watcher, {
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutStatus,
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
|
||||
runGitCommand,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0];
|
||||
checkoutWatcher?.callback(new Error("watcher stopped"), []);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(2);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
const statusCallsAfterRecovery = getCheckoutStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterRecovery);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("adding ignored directories updates filtering without replacing the watcher", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
let ignoredDirectories = "node_modules/\n";
|
||||
const runGitCommand = vi.fn(async (args: string[]) => {
|
||||
return {
|
||||
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
};
|
||||
});
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService(watcher, {
|
||||
getCheckoutStatus,
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
|
||||
runGitCommand,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
|
||||
expect(checkoutWatcher?.ignore).toContain(path.join(REPO_CWD, "node_modules"));
|
||||
|
||||
ignoredDirectories = "node_modules/\nbuild/\n";
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(runGitCommand).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
|
||||
expect(checkoutWatcher?.unsubscribe).not.toHaveBeenCalled();
|
||||
const statusCallsAfterRefresh = getCheckoutStatus.mock.calls.length;
|
||||
checkoutWatcher?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "build", "output.js"), type: "update" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterRefresh);
|
||||
|
||||
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterRefresh + 1);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("removing an ignored directory replaces the watcher", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
let ignoredDirectories = "node_modules/\nbuild/\n";
|
||||
const runGitCommand = vi.fn(async (args: string[]) => {
|
||||
return {
|
||||
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
};
|
||||
});
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService(watcher, {
|
||||
getCheckoutStatus,
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
|
||||
runGitCommand,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const originalWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
|
||||
|
||||
ignoredDirectories = "node_modules/\n";
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(originalWatcher?.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
const replacementWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[1];
|
||||
expect(replacementWatcher?.ignore).not.toContain(path.join(REPO_CWD, "build"));
|
||||
const statusCallsAfterRefresh = getCheckoutStatus.mock.calls.length;
|
||||
replacementWatcher?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "build", "output.js"), type: "update" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterRefresh + 1);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("ignore watcher teardown failure enters polling with a distinct reason", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const logger = createLogger();
|
||||
let ignoredDirectories = "node_modules/\nbuild/\n";
|
||||
const runGitCommand = vi.fn(async (args: string[]) => ({
|
||||
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}));
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService(
|
||||
watcher,
|
||||
{
|
||||
getCheckoutStatus,
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
|
||||
runGitCommand,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0];
|
||||
checkoutWatcher?.unsubscribe.mockRejectedValueOnce(new Error("teardown failed"));
|
||||
ignoredDirectories = "node_modules/\n";
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "watcher_teardown_failed" }),
|
||||
"Working tree watcher unavailable; using bounded polling fallback",
|
||||
);
|
||||
});
|
||||
expect(service.getMetrics().watcherErrorCallbackCount).toBe(0);
|
||||
const statusCallsBeforePoll = getCheckoutStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsBeforePoll);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
@@ -334,6 +334,10 @@ function buildDefaultServiceDeps() {
|
||||
return {
|
||||
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn(async () => {}) })),
|
||||
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutFacts(cwd)),
|
||||
getCheckoutRefDerivedState: vi.fn(async (_cwd, facts, current) => ({
|
||||
...current,
|
||||
upstreamStatus: facts.upstreamStatus,
|
||||
})),
|
||||
getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
|
||||
getCheckoutShortstat: vi.fn(async () => ({
|
||||
additions: 1,
|
||||
@@ -349,7 +353,10 @@ function buildDefaultServiceDeps() {
|
||||
forgeOverrides: { github: createGitHubServiceStub() },
|
||||
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
|
||||
hasOriginRemote: vi.fn(async () => false),
|
||||
runGitFetch: vi.fn(() => createDeferred<void>().promise),
|
||||
runGitFetch: vi.fn(async () => {
|
||||
await createDeferred<void>().promise;
|
||||
return { changes: [], error: null };
|
||||
}),
|
||||
runGitCommand: vi.fn(async () => ({
|
||||
stdout: `${REPO_CWD}\n`,
|
||||
stderr: "",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user