mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 20:05:11 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d13438ba50 | ||
|
|
cf9eaa8d29 | ||
|
|
ec52097924 | ||
|
|
a30b784eb2 | ||
|
|
af6ca46ae3 | ||
|
|
8dc979ec2f | ||
|
|
70aeb7b45b |
@@ -113,6 +113,38 @@ host that is either a known cloud host or one the CLI is already authenticated
|
||||
to. Adapter probes must not make anonymous HTTP requests to remote-derived
|
||||
hosts, and adapters must not route credentials to an unauthenticated host.
|
||||
|
||||
### Web links: `forgeWeb`
|
||||
|
||||
The daemon publishes `forgeWeb` on the workspace descriptor and on
|
||||
`checkout_status_response`. The app builds every forge URL from it instead of
|
||||
parsing `origin` itself.
|
||||
|
||||
- `host` is the browsable host: an `ssh -G` alias such as `github.com-work` and
|
||||
a cloud SSH host such as `ssh.github.com` resolve to the cloud host; self-hosted
|
||||
hosts pass through.
|
||||
- `referenceRepository` is the repository that owns issue and change-request
|
||||
numbers: the parent when `origin` is a fork. `#123` links there; `owner/repo#123`
|
||||
keeps the repository you wrote.
|
||||
- Branch and file links stay on the `origin` repository. A feature branch usually
|
||||
exists only on the fork.
|
||||
|
||||
An adapter opts into `referenceRepository` with the optional
|
||||
`ForgeService.getReferenceRepository`. GitHub reads `parent` from `gh repo view`,
|
||||
GitLab reads `forked_from_project` from `glab api`, and the Gitea family reads
|
||||
`parent` from `tea api`. The lookup always names the `origin` repository
|
||||
explicitly, so it does not depend on `gh`'s default-repository choice, and it
|
||||
rejects on any CLI failure. `packages/server/src/server/forge-web.ts` keeps a
|
||||
successful answer for as long as the remote stays the same. The first snapshot
|
||||
publishes `{ host }` immediately and the reference repository follows in a later
|
||||
update, so a slow or signed-out forge CLI never holds back the checkout's git
|
||||
state or pull request status. A failed lookup (signed out, network down) leaves
|
||||
links on `origin` and is retried by the next refresh or pull request status poll
|
||||
once a minute has passed.
|
||||
|
||||
Daemons older than v0.16.0 omit the field; the app then derives the host from the
|
||||
remote and links `#N` to `origin`. That fallback is tagged `COMPAT(forgeWeb)` in
|
||||
`packages/app/src/git/forge-url.ts`.
|
||||
|
||||
## App
|
||||
|
||||
Each app forge splits into two modules so pure logic never imports the client
|
||||
|
||||
@@ -20,9 +20,16 @@ settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
|
||||
that must bypass the focused text control: composer Return and the top overlay's
|
||||
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
|
||||
after the focused text control has already handled the press, so the pipeline's
|
||||
`preventDefault` cannot stop a key from typing. A key the text control would type
|
||||
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
|
||||
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
|
||||
active in the current focus scope, such as Shift+Tab in the composer.
|
||||
`sources/native.ts` computes the claims from the published bindings and recomputes
|
||||
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
|
||||
text controls do not type them. Android takes no claims: a focused `EditText`
|
||||
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
|
||||
@@ -624,6 +624,21 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute workspace row badge items
|
||||
|
||||
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
|
||||
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
|
||||
items after its own.
|
||||
|
||||
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
|
||||
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
|
||||
the row adds no separator around plugin items; a plugin renders its slot.
|
||||
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
|
||||
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
|
||||
their own group below a separator. A missing key is visible.
|
||||
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
|
||||
stay off them.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { BrowserContext, Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const CROSS_STRUCTURE_MARKDOWN = [
|
||||
"# P1 — High-value UI behavior",
|
||||
@@ -77,20 +74,20 @@ const ASSISTANT_MARKDOWN = [
|
||||
|
||||
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
|
||||
"www.example.com stays",
|
||||
"[www.example.com](http://www.example.com) stays"
|
||||
"[www.example.com](http://www.example.com) stays",
|
||||
)
|
||||
.replace(
|
||||
"`https://example.com/generated` stays",
|
||||
"[`https://example.com/generated`](https://example.com/generated) stays"
|
||||
"[`https://example.com/generated`](https://example.com/generated) stays",
|
||||
)
|
||||
.replace(`${CROSS_STRUCTURE_MARKDOWN}\n\n`, "")
|
||||
.replace(
|
||||
"<https://autolink.example.com>",
|
||||
"[https://autolink.example.com](https://autolink.example.com)"
|
||||
"[https://autolink.example.com](https://autolink.example.com)",
|
||||
)
|
||||
.replace(
|
||||
"3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
|
||||
"3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine"
|
||||
"3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine",
|
||||
)
|
||||
// Rendering drops the blank line before a closing fence, so copying cannot bring it
|
||||
// back. The fence is here to cover a body that ends in more than one newline.
|
||||
@@ -109,19 +106,14 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
|
||||
* Locate assistant rows by source-message identity.
|
||||
*/
|
||||
function assistantMessageBlocks(page: Page): Locator {
|
||||
return page
|
||||
.locator("[data-message-id]")
|
||||
.filter({ has: page.getByTestId("assistant-message") });
|
||||
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
|
||||
}
|
||||
|
||||
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
|
||||
await selectAssistantTextRange(page, "Direct matches:", "ready");
|
||||
}
|
||||
|
||||
async function selectAssistantElement(
|
||||
page: Page,
|
||||
selector: string
|
||||
): Promise<void> {
|
||||
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage.locator(selector).evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
@@ -139,7 +131,7 @@ async function selectAssistantElement(
|
||||
async function selectAssistantElementFromEndOf(
|
||||
page: Page,
|
||||
startText: string,
|
||||
selector: string
|
||||
selector: string,
|
||||
): Promise<void> {
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selected) => {
|
||||
@@ -152,13 +144,9 @@ async function selectAssistantElementFromEndOf(
|
||||
}
|
||||
}
|
||||
}
|
||||
const end = blocks
|
||||
.map((block) => block.querySelector(selected.selector))
|
||||
.find(Boolean);
|
||||
const end = blocks.map((block) => block.querySelector(selected.selector)).find(Boolean);
|
||||
if (!startNode || !end) {
|
||||
throw new Error(
|
||||
`Could not find ${selected.startText} — ${selected.selector}`
|
||||
);
|
||||
throw new Error(`Could not find ${selected.startText} — ${selected.selector}`);
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.setStart(startNode, startNode.length);
|
||||
@@ -167,7 +155,7 @@ async function selectAssistantElementFromEndOf(
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
},
|
||||
{ startText, selector }
|
||||
{ startText, selector },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,11 +188,7 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset + selectedText.length);
|
||||
const box = range.getBoundingClientRect();
|
||||
return {
|
||||
x: box.right - 1,
|
||||
y: box.top + box.height / 2,
|
||||
left: box.left,
|
||||
};
|
||||
return { x: box.right - 1, y: box.top + box.height / 2, left: box.left };
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find ${selectedText}`);
|
||||
@@ -219,50 +203,6 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function dragAssistantTextTo(
|
||||
page: Page,
|
||||
startText: string,
|
||||
endText: string
|
||||
): Promise<string> {
|
||||
const blocks = assistantMessageBlocks(page);
|
||||
const end = await blocks.getByText(endText).boundingBox();
|
||||
if (!end) throw new Error(`Expected rendered ending text: ${endText}`);
|
||||
const start = await assistantTextPoint(page, startText);
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 35, start.y, { steps: 5 });
|
||||
const initialSelection = await page.evaluate(
|
||||
() => window.getSelection()?.toString() ?? ""
|
||||
);
|
||||
await page.mouse.move(end.x + end.width - 1, end.y + end.height / 2, {
|
||||
steps: 20,
|
||||
});
|
||||
await page.mouse.up();
|
||||
return initialSelection;
|
||||
}
|
||||
|
||||
async function assistantTextPoint(
|
||||
page: Page,
|
||||
text: string
|
||||
): Promise<{ x: number; y: number }> {
|
||||
return assistantMessageBlocks(page)
|
||||
.getByText(text)
|
||||
.evaluate((element, value) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null = null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.textContent?.includes(value.slice(0, 4))) break;
|
||||
}
|
||||
if (!node?.textContent)
|
||||
throw new Error(`Expected rendered text node: ${value}`);
|
||||
const range = document.createRange();
|
||||
range.setStart(node, 1);
|
||||
range.setEnd(node, Math.min(2, node.textContent.length));
|
||||
const rect = range.getBoundingClientRect();
|
||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||
}, text);
|
||||
}
|
||||
|
||||
async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
@@ -271,7 +211,7 @@ async function selectAssistantListItemFromMarker(
|
||||
page: Page,
|
||||
listTag: "ol" | "ul",
|
||||
itemText: string,
|
||||
endText: string
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
const item = assistantMessage
|
||||
@@ -294,9 +234,7 @@ async function selectAssistantListItemFromMarker(
|
||||
}
|
||||
}
|
||||
if (!endNode) {
|
||||
throw new Error(
|
||||
`Could not find list item selection end: ${selectedEndText}`
|
||||
);
|
||||
throw new Error(`Could not find list item selection end: ${selectedEndText}`);
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
@@ -311,7 +249,7 @@ async function selectAssistantListItemFromMarker(
|
||||
async function doubleClickAssistantMarkdownText(
|
||||
page: Page,
|
||||
tag: "code" | "strong",
|
||||
text: string
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage
|
||||
@@ -323,17 +261,14 @@ async function doubleClickAssistantMarkdownText(
|
||||
async function selectAssistantTextRange(
|
||||
page: Page,
|
||||
startText: string,
|
||||
endText: string
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selectedRange) => {
|
||||
// The message's text nodes in reading order, across every block row.
|
||||
const nodes: Text[] = [];
|
||||
for (const block of blocks) {
|
||||
const blockWalker = document.createTreeWalker(
|
||||
block,
|
||||
NodeFilter.SHOW_TEXT
|
||||
);
|
||||
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||
let found = blockWalker.nextNode();
|
||||
while (found) {
|
||||
nodes.push(found as Text);
|
||||
@@ -345,12 +280,11 @@ async function selectAssistantTextRange(
|
||||
const end = text.indexOf(selectedRange.endText, start);
|
||||
if (start < 0 || end < 0)
|
||||
throw new Error(
|
||||
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`
|
||||
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
|
||||
);
|
||||
const boundary = (offset: number, isEnd = false): [Text, number] => {
|
||||
for (const node of nodes) {
|
||||
if (offset < node.length || (isEnd && offset === node.length))
|
||||
return [node, offset];
|
||||
if (offset < node.length || (isEnd && offset === node.length)) return [node, offset];
|
||||
offset -= node.length;
|
||||
}
|
||||
throw new Error("Selection extends beyond message");
|
||||
@@ -362,7 +296,7 @@ async function selectAssistantTextRange(
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
},
|
||||
{ startText, endText }
|
||||
{ startText, endText },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -376,17 +310,14 @@ async function selectAssistantTextRange(
|
||||
async function selectAssistantAcrossCodeLines(
|
||||
page: Page,
|
||||
startText: string,
|
||||
endText: string
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selectedRange) => {
|
||||
// The message's text nodes in reading order, across every block row.
|
||||
const nodes: Text[] = [];
|
||||
for (const block of blocks) {
|
||||
const blockWalker = document.createTreeWalker(
|
||||
block,
|
||||
NodeFilter.SHOW_TEXT
|
||||
);
|
||||
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||
let found = blockWalker.nextNode();
|
||||
while (found) {
|
||||
nodes.push(found as Text);
|
||||
@@ -400,8 +331,7 @@ async function selectAssistantAcrossCodeLines(
|
||||
let textNode = walker.nextNode();
|
||||
while (textNode) {
|
||||
if (!startNode) {
|
||||
const offset =
|
||||
textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
|
||||
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
|
||||
if (offset >= 0) {
|
||||
startNode = textNode;
|
||||
startOffset = offset;
|
||||
@@ -409,8 +339,7 @@ async function selectAssistantAcrossCodeLines(
|
||||
textNode = walker.nextNode();
|
||||
continue;
|
||||
}
|
||||
const endOffset =
|
||||
textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
|
||||
const endOffset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
|
||||
if (endOffset >= 0) {
|
||||
const range = document.createRange();
|
||||
range.setStart(startNode, startOffset);
|
||||
@@ -423,10 +352,10 @@ async function selectAssistantAcrossCodeLines(
|
||||
textNode = walker.nextNode();
|
||||
}
|
||||
throw new Error(
|
||||
`Could not find a code range: ${selectedRange.startText} — ${selectedRange.endText}`
|
||||
`Could not find a code range: ${selectedRange.startText} — ${selectedRange.endText}`,
|
||||
);
|
||||
},
|
||||
{ startText, endText }
|
||||
{ startText, endText },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -458,10 +387,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ uiFontFamily: "serif" })
|
||||
);
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "serif" }));
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-copy-",
|
||||
@@ -475,7 +401,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000
|
||||
30_000,
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
|
||||
@@ -485,15 +411,11 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
["em", "emphasized prose"],
|
||||
["s", "struck prose"],
|
||||
]) {
|
||||
const formattedProse = assistantMessage
|
||||
.locator(tag)
|
||||
.filter({ hasText: text });
|
||||
const formattedProse = assistantMessage.locator(tag).filter({ hasText: text });
|
||||
await expect(formattedProse).toHaveCSS("font-family", "serif");
|
||||
await expect(formattedProse).not.toHaveAttribute("data-pmono");
|
||||
}
|
||||
const inlineCode = assistantMessage
|
||||
.locator("code")
|
||||
.filter({ hasText: "apply_patch" });
|
||||
const inlineCode = assistantMessage.locator("code").filter({ hasText: "apply_patch" });
|
||||
await expect(inlineCode).toHaveCSS("font-family", /monospace|Mono/);
|
||||
|
||||
await selectStructuredClipboardFixture(page);
|
||||
@@ -505,44 +427,38 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
expect(clipboard.html).not.toContain("<li>");
|
||||
expect(clipboard.html).toContain("<div>- ");
|
||||
expect(clipboard.html).toContain(
|
||||
'<strong><a href="https://example.com/issues/1">First issue</a></strong>'
|
||||
'<strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
expect(clipboard.html).toContain("<code>apply_patch</code>");
|
||||
expect(clipboard.html).toContain("<div>5. Fifth item</div>");
|
||||
expect(clipboard.html).toContain("A hard break<br>");
|
||||
expect(clipboard.html).toContain(
|
||||
'<a href="http://www.example.com/">www.example.com</a> stays plain Markdown text.'
|
||||
'<a href="http://www.example.com/">www.example.com</a> stays plain Markdown text.',
|
||||
);
|
||||
expect(clipboard.html).toContain(
|
||||
'<a href="https://docs.example.com/" title="Reference">docs</a>'
|
||||
'<a href="https://docs.example.com/" title="Reference">docs</a>',
|
||||
);
|
||||
expect(clipboard.html).toContain(
|
||||
'<a href="https://autolink.example.com/">https://autolink.example.com</a>'
|
||||
'<a href="https://autolink.example.com/">https://autolink.example.com</a>',
|
||||
);
|
||||
expect(clipboard.html).toContain(
|
||||
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>'
|
||||
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
|
||||
);
|
||||
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
|
||||
expect(clipboard.html).toContain(
|
||||
"<code>https://example.com/generated</code>"
|
||||
);
|
||||
expect(clipboard.html).toContain(
|
||||
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>'
|
||||
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
|
||||
);
|
||||
expect(clipboard.html).toContain('<code class="language-typescript">');
|
||||
expect(clipboard.html).toContain(
|
||||
'<pre><code class="language-text">before\n```\nafter\n</code></pre>'
|
||||
'<pre><code class="language-text">before\n```\nafter\n</code></pre>',
|
||||
);
|
||||
expect(clipboard.html).toContain("<table>");
|
||||
expect(clipboard.html).toContain(
|
||||
'<td style="text-align:left">Current | active</td>'
|
||||
);
|
||||
expect(clipboard.html).toContain('<td style="text-align:left">Current | active</td>');
|
||||
expect(clipboard.html.match(/<td\b/g)).toHaveLength(3);
|
||||
expect(clipboard.html).toContain("<s>obsolete</s>");
|
||||
expect(clipboard.html).toContain('<th style="text-align:left">Left</th>');
|
||||
expect(clipboard.html).toContain('<th style="text-align:right">Right</th>');
|
||||
expect(clipboard.html).toContain(
|
||||
'<th style="text-align:center">Center</th>'
|
||||
);
|
||||
expect(clipboard.html).toContain('<th style="text-align:center">Center</th>');
|
||||
|
||||
await doubleClickAssistantMarkdownText(page, "code", "apply_patch");
|
||||
await copySelection(page);
|
||||
@@ -561,7 +477,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await selectAssistantTextRange(
|
||||
page,
|
||||
"P1 — High-value UI behavior",
|
||||
"Smoke with an older saved Fabel/model alias and with daemon/client version drift."
|
||||
"Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
|
||||
);
|
||||
await copySelection(page);
|
||||
|
||||
@@ -576,10 +492,10 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
" - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.",
|
||||
" - Confirm Opus 5 exposes the Fast toggle.",
|
||||
" - Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
|
||||
].join("\n")
|
||||
].join("\n"),
|
||||
);
|
||||
expect(headingAndListClipboard.html).toMatch(
|
||||
/<h1>P1 — High-value UI behavior<\/h1>\s*<div>5\. Claude model and thinking preferences\s*<\/div><div>- Switch Claude models/
|
||||
/<h1>P1 — High-value UI behavior<\/h1>\s*<div>5\. Claude model and thinking preferences\s*<\/div><div>- Switch Claude models/,
|
||||
);
|
||||
|
||||
await selectAssistantText(page, "First");
|
||||
@@ -589,11 +505,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
expect(partialClipboard.plainText).toBe("First");
|
||||
expect(partialClipboard.html).toContain("<p>First</p>");
|
||||
|
||||
await selectAssistantTextRange(
|
||||
page,
|
||||
"Direct matches:",
|
||||
"repeated sandbox setup."
|
||||
);
|
||||
await selectAssistantTextRange(page, "Direct matches:", "repeated sandbox setup.");
|
||||
await copySelection(page);
|
||||
|
||||
const paragraphAndListClipboard = await readRichClipboard(page);
|
||||
@@ -605,36 +517,26 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
"",
|
||||
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
|
||||
"- [Second issue](https://example.com/issues/2): repeated sandbox setup.",
|
||||
].join("\n")
|
||||
].join("\n"),
|
||||
);
|
||||
expect(paragraphAndListClipboard.html).not.toContain("<ul>");
|
||||
expect(paragraphAndListClipboard.html).not.toContain("<li>");
|
||||
expect(paragraphAndListClipboard.html).toContain(
|
||||
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>'
|
||||
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
|
||||
await selectAssistantListItemFromMarker(
|
||||
page,
|
||||
"ul",
|
||||
"First issue",
|
||||
"failure."
|
||||
);
|
||||
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."
|
||||
"- **[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>'
|
||||
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
|
||||
await selectAssistantListItemFromMarker(
|
||||
page,
|
||||
"ol",
|
||||
"Sixth item",
|
||||
"Sixth item"
|
||||
);
|
||||
await selectAssistantListItemFromMarker(page, "ol", "Sixth item", "Sixth item");
|
||||
await copySelection(page);
|
||||
|
||||
const draggedNumberClipboard = await readRichClipboard(page);
|
||||
@@ -679,9 +581,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await copySelection(page);
|
||||
|
||||
const crossBlockClipboard = await readRichClipboard(page);
|
||||
expect(crossBlockClipboard.plainText).toBe(
|
||||
"Seventh item\n\n8. Eighth item\n\nNested list:"
|
||||
);
|
||||
expect(crossBlockClipboard.plainText).toBe("Seventh item\n\n8. Eighth item\n\nNested list:");
|
||||
expect(crossBlockClipboard.html).toContain("<div>8. Eighth item</div>");
|
||||
|
||||
await selectAssistantText(page, "Current");
|
||||
@@ -708,37 +608,31 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await copySelection(page);
|
||||
|
||||
const codeLinesClipboard = await readRichClipboard(page);
|
||||
expect(codeLinesClipboard.plainText).toBe(
|
||||
'const answer = "yes";\n return'
|
||||
);
|
||||
expect(codeLinesClipboard.html).toContain(
|
||||
'<pre><code class="language-typescript">'
|
||||
);
|
||||
expect(codeLinesClipboard.plainText).toBe('const answer = "yes";\n return');
|
||||
expect(codeLinesClipboard.html).toContain('<pre><code class="language-typescript">');
|
||||
|
||||
// Selecting inside code expresses an intent to copy code, even when the selection
|
||||
// contains every character in the block.
|
||||
await selectAssistantElement(
|
||||
page,
|
||||
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]'
|
||||
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
|
||||
);
|
||||
await copySelection(page);
|
||||
|
||||
const completeCodeClipboard = await readRichClipboard(page);
|
||||
expect(completeCodeClipboard.plainText).toBe(
|
||||
'const answer = "yes";\n return answer;'
|
||||
);
|
||||
expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;');
|
||||
|
||||
// Touching the block before it without selecting any of its text is still a
|
||||
// selection inside the code.
|
||||
await selectAssistantElementFromEndOf(
|
||||
page,
|
||||
"not a generated link.",
|
||||
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]'
|
||||
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
|
||||
);
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'const answer = "yes";\n return answer;'
|
||||
'const answer = "yes";\n return answer;',
|
||||
);
|
||||
|
||||
// Crossing the block boundary expresses an intent to carry its Markdown structure.
|
||||
@@ -747,19 +641,15 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
|
||||
const crossedCodeClipboard = await readRichClipboard(page);
|
||||
expect(crossedCodeClipboard.plainText).toBe(
|
||||
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.'
|
||||
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Crossing into part of the block keeps the selected code lines as a fenced block.
|
||||
await selectAssistantAcrossCodeLines(
|
||||
page,
|
||||
"not a generated link.",
|
||||
"return"
|
||||
);
|
||||
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```'
|
||||
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
|
||||
);
|
||||
|
||||
// So does starting partway through the block and leaving it.
|
||||
@@ -767,7 +657,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.'
|
||||
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Selecting a whole line runs off its end into the newline node. A trailing
|
||||
@@ -775,28 +665,20 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await selectAssistantAcrossCodeLines(page, "const", " ");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'const answer = "yes";'
|
||||
);
|
||||
expect((await readRichClipboard(page)).plainText).toBe('const answer = "yes";');
|
||||
|
||||
// The block's own Copy button had the same hazard: a Markdown fence body ends in
|
||||
// a newline, and the button copied it raw.
|
||||
const typescriptFence = assistantMessage.locator(
|
||||
'[data-paseo-markdown-language="typescript"]'
|
||||
);
|
||||
const typescriptFence = assistantMessage.locator('[data-paseo-markdown-language="typescript"]');
|
||||
// The button is opacity 0 / pointerEvents none until the fence is hovered.
|
||||
await typescriptFence.hover();
|
||||
await typescriptFence.locator("[data-paseo-markdown-ignore]").click();
|
||||
|
||||
expect(await readPlainClipboard(page)).toBe(
|
||||
'const answer = "yes";\n return answer;'
|
||||
);
|
||||
expect(await readPlainClipboard(page)).toBe('const answer = "yes";\n return answer;');
|
||||
|
||||
// A blank line before the closing fence leaves the body ending in two newlines,
|
||||
// so stripping only the terminal one still hands the terminal an executable line.
|
||||
const bashFence = assistantMessage.locator(
|
||||
'[data-paseo-markdown-language="bash"]'
|
||||
);
|
||||
const bashFence = assistantMessage.locator('[data-paseo-markdown-language="bash"]');
|
||||
await bashFence.hover();
|
||||
await bashFence.locator("[data-paseo-markdown-ignore]").click();
|
||||
|
||||
@@ -832,28 +714,22 @@ test("copying a selection across an assistant image keeps its Markdown source an
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000
|
||||
30_000,
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
|
||||
|
||||
await selectAssistantTextRange(
|
||||
page,
|
||||
"Before the image.",
|
||||
"After the image."
|
||||
);
|
||||
await selectAssistantTextRange(page, "Before the image.", "After the image.");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
`Before the image.\n\n\n\nAfter the image.`
|
||||
`Before the image.\n\n\n\nAfter the image.`,
|
||||
);
|
||||
|
||||
await selectAssistantImage(page);
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
``
|
||||
);
|
||||
expect((await readRichClipboard(page)).plainText).toBe(``);
|
||||
|
||||
await dragFromBelowImageTo(page, "After the");
|
||||
await copySelection(page);
|
||||
@@ -869,9 +745,7 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("a mouse drag selects across assistant list, quote, and code blocks", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("a mouse drag selects across assistant list, quote, and code blocks", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
@@ -897,115 +771,27 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000
|
||||
30_000,
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
await allowRichClipboard(page.context());
|
||||
|
||||
for (const startText of [
|
||||
"First paragraph.",
|
||||
"List item.",
|
||||
"Quote text.",
|
||||
"const answer = 1;",
|
||||
]) {
|
||||
const initialSelection = await dragAssistantTextTo(
|
||||
page,
|
||||
startText,
|
||||
"Third paragraph."
|
||||
);
|
||||
expect(
|
||||
initialSelection,
|
||||
`drag starts selecting inside ${startText}`
|
||||
).toContain(startText.slice(2, 4));
|
||||
const selectedFromBlock = await page.evaluate(
|
||||
() => window.getSelection()?.toString() ?? ""
|
||||
);
|
||||
expect(selectedFromBlock, `drag starting in ${startText}`).toContain(
|
||||
startText.slice(2)
|
||||
);
|
||||
expect(selectedFromBlock, `drag ending in third paragraph`).toContain(
|
||||
"Third paragraph."
|
||||
);
|
||||
await copySelection(page);
|
||||
const copied = await readRichClipboard(page);
|
||||
expect(copied.plainText, `copy starting in ${startText}`).toContain(
|
||||
startText.slice(2)
|
||||
);
|
||||
expect(copied.plainText).toContain("Third paragraph.");
|
||||
}
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("on a desktop browser without touch", () => {
|
||||
// Reported in #9504: a drag that starts partway through text only moves a collapsed caret.
|
||||
|
||||
test("a mouse drag selects across assistant list, quote, and code blocks", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
initialPrompt: "Render structured text.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: [
|
||||
"First paragraph.",
|
||||
"",
|
||||
"- List item.",
|
||||
"",
|
||||
"> Quote text.",
|
||||
"",
|
||||
"```ts",
|
||||
"const answer = 1;",
|
||||
"```",
|
||||
"",
|
||||
"Third paragraph.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
await allowRichClipboard(page.context());
|
||||
|
||||
for (const startText of [
|
||||
"First paragraph.",
|
||||
"List item.",
|
||||
"Quote text.",
|
||||
"const answer = 1;",
|
||||
]) {
|
||||
const initialSelection = await dragAssistantTextTo(
|
||||
page,
|
||||
startText,
|
||||
"Third paragraph."
|
||||
);
|
||||
expect(
|
||||
initialSelection,
|
||||
`drag starts selecting inside ${startText}`
|
||||
).toContain(startText.slice(2, 4));
|
||||
const selectedFromBlock = await page.evaluate(
|
||||
() => window.getSelection()?.toString() ?? ""
|
||||
);
|
||||
expect(selectedFromBlock, `drag starting in ${startText}`).toContain(
|
||||
startText.slice(2)
|
||||
);
|
||||
expect(selectedFromBlock, `drag ending in third paragraph`).toContain(
|
||||
"Third paragraph."
|
||||
);
|
||||
await copySelection(page);
|
||||
const copied = await readRichClipboard(page);
|
||||
expect(copied.plainText, `copy starting in ${startText}`).toContain(
|
||||
startText.slice(2)
|
||||
);
|
||||
expect(copied.plainText).toContain("Third paragraph.");
|
||||
const blocks = assistantMessageBlocks(page);
|
||||
const first = await blocks.getByText("First paragraph.").boundingBox();
|
||||
const third = await blocks.getByText("Third paragraph.").boundingBox();
|
||||
if (!first || !third) {
|
||||
throw new Error("Expected rendered paragraphs");
|
||||
}
|
||||
|
||||
await page.mouse.move(first.x + 1, first.y + first.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(third.x + third.width - 1, third.y + third.height / 2, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("First paragraph.");
|
||||
expect(selected).toContain("List item.");
|
||||
expect(selected).toContain("Quote text.");
|
||||
expect(selected).toContain("const answer = 1;");
|
||||
expect(selected).toContain("Third paragraph.");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openMobileAgentSidebar,
|
||||
openSidebarDisplayPage,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "row-badges-e2e";
|
||||
const TITLE = "Badge host workspace";
|
||||
|
||||
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function Review() {
|
||||
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
|
||||
}
|
||||
|
||||
function Docs() {
|
||||
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
|
||||
}
|
||||
|
||||
function IconOnly() {
|
||||
return <WorkspaceRowBadge icon="Star" />;
|
||||
}
|
||||
|
||||
function EmptyLabel() {
|
||||
return <WorkspaceRowBadge icon="Circle" label="" />;
|
||||
}
|
||||
|
||||
function Quiet() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function Broken() {
|
||||
throw new Error("badge failed");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
|
||||
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
|
||||
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
|
||||
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
|
||||
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
|
||||
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
|
||||
const LABELS = { review: "In review", docs: "Docs" } as const;
|
||||
|
||||
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
|
||||
return badge(page, itemId).locator("xpath=../..");
|
||||
}
|
||||
|
||||
function badge(page: Page, itemId: keyof typeof LABELS) {
|
||||
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
|
||||
}
|
||||
|
||||
async function toggleShowItem(page: Page, label: string, touch: boolean) {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: label, exact: true }).click();
|
||||
if (touch) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
} else {
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
}
|
||||
}
|
||||
|
||||
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await expect(badge(page, "review")).toContainText("In review");
|
||||
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
|
||||
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
|
||||
// An icon-only badge is named by its item's title.
|
||||
await expect(
|
||||
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(TITLE).first()).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("wide.png") });
|
||||
});
|
||||
|
||||
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
|
||||
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("show-menu.png") });
|
||||
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
// The first badge is gone, so the next one leads the line without a "·".
|
||||
await expect(badgeLine(page, "docs")).toHaveText("Docs");
|
||||
await toggleShowItem(page, "Review state", false);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the badge draws on a compact layout", async () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("compact.png") });
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsage,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
@@ -79,12 +80,17 @@ function twoHoursAgo(): string {
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
async function qaScreenshot(page: Page, name: string) {
|
||||
async function qaScreenshot(page: Page, name: string, target: "page" | "summary" = "page") {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
const file = path.join(directory, `${name}.png`);
|
||||
if (target === "summary") {
|
||||
await usageItem(page).screenshot({ path: file });
|
||||
return;
|
||||
}
|
||||
await page.screenshot({ path: file });
|
||||
}
|
||||
|
||||
function hostFilter(page: Page) {
|
||||
@@ -628,6 +634,15 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toBeVisible();
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await qaScreenshot(page, `usage-error-detail-${compact ? "compact" : "desktop"}`);
|
||||
await closeUsage(page);
|
||||
await qaScreenshot(page, `usage-error-summary-${compact ? "compact" : "desktop"}`, "summary");
|
||||
await openUsageFromItem(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
@@ -639,6 +654,8 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
@@ -647,6 +664,9 @@ for (const compact of [false, true]) {
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await expectPinnedUsage(page, ["43% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
await expect(usageItem(page)).not.toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
|
||||
@@ -8,6 +8,7 @@ private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
private var overlayKeys: Set<String> = []
|
||||
private var claimedShortcuts: [ClaimedShortcut] = []
|
||||
|
||||
/// UIKit spells non-character keys as sentinel input strings rather than as the
|
||||
/// character they would type. JS sends the DOM `code` and the mapping lives
|
||||
@@ -19,6 +20,42 @@ private let namedKeyInputs: [String: String] = [
|
||||
"Enter": "\r",
|
||||
]
|
||||
|
||||
/// A shortcut the window takes from the focused text control: Shift+Tab would
|
||||
/// otherwise insert a tab, and Option+Shift+[ would type a quote.
|
||||
private struct ClaimedShortcut {
|
||||
let input: String
|
||||
let modifierFlags: UIKeyModifierFlags
|
||||
}
|
||||
|
||||
struct ClaimedShortcutRecord: Record {
|
||||
@Field var code: String = ""
|
||||
@Field var shift: Bool = false
|
||||
@Field var alt: Bool = false
|
||||
}
|
||||
|
||||
private let claimableKeyInputs: [String: String] = [
|
||||
"Tab": "\t", "Space": " ", "Enter": "\r", "Escape": UIKeyCommand.inputEscape,
|
||||
"ArrowUp": UIKeyCommand.inputUpArrow, "ArrowDown": UIKeyCommand.inputDownArrow,
|
||||
"ArrowLeft": UIKeyCommand.inputLeftArrow, "ArrowRight": UIKeyCommand.inputRightArrow,
|
||||
"Minus": "-", "Equal": "=", "BracketLeft": "[", "BracketRight": "]", "Backslash": "\\",
|
||||
"Semicolon": ";", "Quote": "'", "Backquote": "`", "Comma": ",", "Period": ".", "Slash": "/",
|
||||
]
|
||||
|
||||
/// UIKit matches a key command against the unmodified character, so
|
||||
/// Option+Shift+[ is registered as "[" with both modifier flags.
|
||||
private func claimedShortcut(_ record: ClaimedShortcutRecord) -> ClaimedShortcut? {
|
||||
let code = record.code
|
||||
let input: String?
|
||||
if code.count == 4 && code.hasPrefix("Key") { input = code.suffix(1).lowercased() }
|
||||
else if code.count == 6 && code.hasPrefix("Digit") { input = String(code.suffix(1)) }
|
||||
else { input = claimableKeyInputs[code] }
|
||||
guard let input else { return nil }
|
||||
var modifierFlags: UIKeyModifierFlags = []
|
||||
if record.shift { modifierFlags.insert(.shift) }
|
||||
if record.alt { modifierFlags.insert(.alternate) }
|
||||
return ClaimedShortcut(input: input, modifierFlags: modifierFlags)
|
||||
}
|
||||
|
||||
/// Whether the first responder is mid-IME-composition.
|
||||
///
|
||||
/// A committing key has to stay out of the way while a CJK candidate is being
|
||||
@@ -62,6 +99,14 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
}
|
||||
}
|
||||
|
||||
Function("setClaimedShortcuts") { (shortcuts: [ClaimedShortcutRecord]) in
|
||||
let claimed = shortcuts.compactMap(claimedShortcut)
|
||||
DispatchQueue.main.async {
|
||||
claimedShortcuts = claimed
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||||
self.keyboardObservers = []
|
||||
@@ -69,6 +114,7 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
activeModule = nil
|
||||
isHardwareSubmitEnabled = false
|
||||
overlayKeys = []
|
||||
claimedShortcuts = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +185,10 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
{
|
||||
commands.append(makeSubmitCommand())
|
||||
}
|
||||
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
|
||||
if !isComposingText() {
|
||||
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
|
||||
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -172,6 +221,18 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
return command
|
||||
}
|
||||
|
||||
/// The window's press stream still emits the shortcut; the command only keeps
|
||||
/// the key away from the text control.
|
||||
private func makeClaimedCommand(_ shortcut: ClaimedShortcut) -> UIKeyCommand {
|
||||
let command = UIKeyCommand(
|
||||
input: shortcut.input,
|
||||
modifierFlags: shortcut.modifierFlags,
|
||||
action: #selector(handleHardwareKeyboardShortcut(_:))
|
||||
)
|
||||
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
|
||||
return command
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
|
||||
guard canSubmitCurrentTextInput() else {
|
||||
|
||||
@@ -58,6 +58,8 @@ import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
|
||||
import { useWorkspaceLabelProjection } from "@/workspace-labels";
|
||||
import { WorkspaceLabelDot } from "@/workspace-labels/swatch";
|
||||
import { WorkspaceLabelManagerModal } from "@/workspace-labels/manager-modal";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import type { PluginWorkspaceRowBadgeItemGroup } from "@/plugins/workspace-row-badge-items";
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -65,6 +67,7 @@ const ThemedSettings2 = withUnistyles(Settings2);
|
||||
/** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircle = withUnistyles(Circle);
|
||||
const ThemedPluginIconView = withUnistyles(PluginIconView);
|
||||
|
||||
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
|
||||
const OPTION_ICON_SIZE = 14;
|
||||
@@ -607,9 +610,9 @@ function OptionList<Value extends string>({
|
||||
}
|
||||
|
||||
/**
|
||||
* Two groups, split by the separator. Above it, what a row may say about a workspace — each one
|
||||
* independent. Below it, the one thing the slot to the right of the title holds, so picking the
|
||||
* one already showing empties the slot and gives the width back to the title.
|
||||
* Three groups: Paseo's own row items, plugin badge items, then the one thing the slot to the
|
||||
* right of the title holds. Picking the trailing choice already showing empties the slot and
|
||||
* gives the width back to the title.
|
||||
*
|
||||
* CI is the one item above the separator with three answers rather than two, so it opens a page
|
||||
* instead of ticking, and it goes last: a row that navigates does not belong in the middle of a
|
||||
@@ -632,6 +635,12 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
/>
|
||||
))}
|
||||
<ChecksSubTrigger />
|
||||
{preferences.pluginRowBadgeItems.length > 0 ? (
|
||||
<MenuSeparator testID="sidebar-plugin-row-badge-items-separator" />
|
||||
) : null}
|
||||
{preferences.pluginRowBadgeItems.map((item) => (
|
||||
<PluginRowBadgeItemOption key={item.key} item={item} preferences={preferences} />
|
||||
))}
|
||||
<MenuSeparator />
|
||||
{TRAILING_CHOICES.map((choice) => (
|
||||
<OptionItem
|
||||
@@ -649,6 +658,37 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** A plugin's workspace row badge item, toggled like the row's own. */
|
||||
function PluginRowBadgeItemOption({
|
||||
item,
|
||||
preferences,
|
||||
}: {
|
||||
item: PluginWorkspaceRowBadgeItemGroup;
|
||||
preferences: Preferences;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(
|
||||
() => preferences.togglePluginRowBadgeItem(item.key),
|
||||
[item.key, preferences],
|
||||
);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<ThemedPluginIconView icon={item.icon} size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />
|
||||
),
|
||||
[item.icon],
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
selected={preferences.isPluginRowBadgeItemVisible(item.key)}
|
||||
leading={leading}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleSelect}
|
||||
testID={`sidebar-row-badge-item-plugin-${item.pluginId}-${item.itemId}`}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* No value on the row, only the chevron. The three answers are all long enough that the value
|
||||
* fills the row and stops reading as an answer sitting at the right edge — it turns into a second
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
type SidebarGroupMode,
|
||||
type SidebarLabelFilter,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import {
|
||||
groupPluginWorkspaceRowBadgeItems,
|
||||
isWorkspaceRowBadgeItemVisible,
|
||||
type PluginWorkspaceRowBadgeItemGroup,
|
||||
} from "@/plugins/workspace-row-badge-items";
|
||||
import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display";
|
||||
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
|
||||
|
||||
@@ -27,6 +33,10 @@ export interface SidebarDisplayPreferences {
|
||||
setTitleSource: (source: WorkspaceTitleSource) => void;
|
||||
rowItems: SidebarRowItems;
|
||||
toggleRowItem: (item: SidebarRowItem) => void;
|
||||
/** Workspace row badge items contributed by installed plugins, one per plugin item across hosts. */
|
||||
pluginRowBadgeItems: readonly PluginWorkspaceRowBadgeItemGroup[];
|
||||
isPluginRowBadgeItemVisible: (key: string) => boolean;
|
||||
togglePluginRowBadgeItem: (key: string) => void;
|
||||
checksDisplay: SidebarChecksDisplay;
|
||||
setChecksDisplay: (display: SidebarChecksDisplay) => void;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
@@ -75,9 +85,30 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
sidebarWorkspaceTrailing,
|
||||
sidebarRowItems,
|
||||
sidebarChecksDisplay,
|
||||
sidebarPluginRowBadgeItems,
|
||||
},
|
||||
updateSettings,
|
||||
} = useAppSettings();
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
const pluginRowBadgeItems = useMemo(
|
||||
() => groupPluginWorkspaceRowBadgeItems(installedPlugins),
|
||||
[installedPlugins],
|
||||
);
|
||||
const isPluginRowBadgeItemVisible = useCallback(
|
||||
(key: string) => isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
[sidebarPluginRowBadgeItems],
|
||||
);
|
||||
const togglePluginRowBadgeItem = useCallback(
|
||||
(key: string) => {
|
||||
void updateSettings({
|
||||
sidebarPluginRowBadgeItems: {
|
||||
...sidebarPluginRowBadgeItems,
|
||||
[key]: !isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateSettings, sidebarPluginRowBadgeItems],
|
||||
);
|
||||
|
||||
const setTitleSource = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
@@ -123,6 +154,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
rowItems: sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
checksDisplay: sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
trailing: sidebarWorkspaceTrailing,
|
||||
@@ -148,6 +182,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
sidebarWorkspaceTrailing,
|
||||
|
||||
@@ -186,6 +186,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
prHint={workspace.prHint}
|
||||
serviceSummary={serviceSummary}
|
||||
labels={labels}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -16,6 +16,10 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
|
||||
import {
|
||||
PluginWorkspaceRowBadgeItems,
|
||||
usePluginWorkspaceRowBadgeItems,
|
||||
} from "@/plugins/workspace-row-badge-items";
|
||||
import { CheckIndicator } from "./check-indicator";
|
||||
import type { CheckSummary, CheckSummaryState } from "./check-summary";
|
||||
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
|
||||
@@ -59,6 +63,9 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
* state has its own colors — a label is a name a person chose, so it carries its own identity
|
||||
* color on a tint instead (see `WorkspaceLabelChip`). It sits last so the reported facts are
|
||||
* read first, and it stays the same height as the rest of the line.
|
||||
*
|
||||
* Plugin items follow everything the row knows itself. A plugin renders its slot as it likes:
|
||||
* the row adds no separator, since only the plugin knows whether it has anything to say.
|
||||
*/
|
||||
export function WorkspaceMetaRow({
|
||||
currentBranch,
|
||||
@@ -67,6 +74,7 @@ export function WorkspaceMetaRow({
|
||||
prHint,
|
||||
serviceSummary,
|
||||
labels = EMPTY_LABELS,
|
||||
workspace = null,
|
||||
}: {
|
||||
currentBranch: string | null;
|
||||
projectName: string | null;
|
||||
@@ -74,6 +82,8 @@ export function WorkspaceMetaRow({
|
||||
prHint: PrHint | null;
|
||||
serviceSummary: WorkspaceServiceSummary | null;
|
||||
labels?: readonly WorkspaceLabelDefinition[];
|
||||
/** The real workspace behind the row; previews leave it out and get no plugin items. */
|
||||
workspace?: { serverId: string; workspaceId: string } | null;
|
||||
}) {
|
||||
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
|
||||
const items = selectMetaRowItems({
|
||||
@@ -86,8 +96,9 @@ export function WorkspaceMetaRow({
|
||||
visible: rowItems,
|
||||
checksDisplay,
|
||||
});
|
||||
const pluginItems = usePluginWorkspaceRowBadgeItems(workspace?.serverId ?? null);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
if (items.length === 0 && (!workspace || pluginItems.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
@@ -97,6 +108,9 @@ export function WorkspaceMetaRow({
|
||||
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
|
||||
</Fragment>
|
||||
))}
|
||||
{workspace ? (
|
||||
<PluginWorkspaceRowBadgeItems targets={pluginItems} workspaceId={workspace.workspaceId} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
|
||||
|
||||
@@ -9,13 +10,27 @@ const ForgeShorthandContext = createContext<ForgeShorthand | null>(null);
|
||||
export function ForgeShorthandProvider({
|
||||
forge,
|
||||
remoteUrl,
|
||||
forgeWeb,
|
||||
children,
|
||||
}: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb: ForgeWeb | null | undefined;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
|
||||
// Keyed on the strings: the descriptor hands over a new `forgeWeb` object whenever any
|
||||
// other workspace field changes, and a new context value re-renders every Markdown message.
|
||||
const webHost = forgeWeb?.host;
|
||||
const referenceRepository = forgeWeb?.referenceRepository;
|
||||
const value = useMemo(
|
||||
() =>
|
||||
createForgeShorthand({
|
||||
forge,
|
||||
remoteUrl,
|
||||
forgeWeb: webHost ? { host: webHost, referenceRepository } : null,
|
||||
}),
|
||||
[forge, remoteUrl, webHost, referenceRepository],
|
||||
);
|
||||
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@ describe("createForgeShorthand", () => {
|
||||
expect(gitlab.resolve("g/sub/q#3")).toBe("https://gitlab.com/g/sub/q/-/issues/3");
|
||||
});
|
||||
|
||||
it("links numbers to the repository the daemon says owns them, but keeps explicit references", () => {
|
||||
const fork = createForgeShorthand({
|
||||
forge: "github",
|
||||
remoteUrl: "git@github.com-work:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
})!;
|
||||
|
||||
expect(fork.resolve("#123")).toBe("https://github.com/main-owner/repo/issues/123");
|
||||
expect(fork.resolve("acme/tools#4")).toBe("https://github.com/acme/tools/issues/4");
|
||||
});
|
||||
|
||||
it("only recognizes the sigils the forge declares", () => {
|
||||
expect(matches(github, "see #1 and !2")).toEqual(["#1"]);
|
||||
expect(github.resolve("!2")).toBeNull();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
||||
import { forgeFromRemoteUrl } from "@/git/forge";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
@@ -26,12 +27,15 @@ const REFERENCE = new RegExp(String.raw`^(?:(${REPO}))?([#!])(${NUMBER})$`);
|
||||
export function createForgeShorthand(input: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
}): ForgeShorthand | null {
|
||||
const remoteUrl = input.remoteUrl;
|
||||
const forge = input.forge || forgeFromRemoteUrl(remoteUrl);
|
||||
if (!forge || !remoteUrl) return null;
|
||||
const sigils = forgeSigils(forge).filter(
|
||||
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
|
||||
(sigil) =>
|
||||
buildForgeReferenceUrl(forge, { remoteUrl, forgeWeb: input.forgeWeb, sigil, number: 1 }) !==
|
||||
null,
|
||||
);
|
||||
if (sigils.length === 0) return null;
|
||||
return {
|
||||
@@ -41,6 +45,7 @@ export function createForgeShorthand(input: {
|
||||
if (!match) return null;
|
||||
return buildForgeReferenceUrl(forge, {
|
||||
remoteUrl,
|
||||
forgeWeb: input.forgeWeb,
|
||||
repo: match[1] ?? null,
|
||||
sigil: match[2] as ForgeReferenceSigil,
|
||||
number: Number(match[3]),
|
||||
|
||||
@@ -326,3 +326,81 @@ describe("hasForgeWebUrls", () => {
|
||||
expect(hasForgeWebUrls("bitbucket")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links resolved by the daemon (forgeWeb)", () => {
|
||||
const sshAliasRemote = "git@github.com-work:acme/repo.git";
|
||||
|
||||
it("links #N on the web host the daemon resolved for an SSH config alias", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: sshAliasRemote,
|
||||
forgeWeb: { host: "github.com" },
|
||||
sigil: "#",
|
||||
number: 1,
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/issues/1");
|
||||
});
|
||||
|
||||
it("opens the branch and a file on the resolved web host for an SSH config alias", () => {
|
||||
const forgeWeb = { host: "github.com" };
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("github", { remoteUrl: sshAliasRemote, forgeWeb, branch: "main" }),
|
||||
).toBe("https://github.com/acme/repo/tree/main");
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: sshAliasRemote,
|
||||
forgeWeb,
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
|
||||
});
|
||||
|
||||
it("links #N to the repository that owns the numbers, such as a fork's parent", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
sigil: "#",
|
||||
number: 123,
|
||||
}),
|
||||
).toBe("https://github.com/main-owner/repo/issues/123");
|
||||
});
|
||||
|
||||
it("keeps an explicit owner/repo#N reference on the named repository", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
repo: "other/tools",
|
||||
sigil: "#",
|
||||
number: 7,
|
||||
}),
|
||||
).toBe("https://github.com/other/tools/issues/7");
|
||||
});
|
||||
|
||||
it("keeps the branch and file links on the origin repository for a fork", () => {
|
||||
const input = {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
branch: "feature/only-on-fork",
|
||||
};
|
||||
expect(buildForgeBranchTreeUrl("github", input)).toBe(
|
||||
"https://github.com/fork-owner/repo/tree/feature/only-on-fork",
|
||||
);
|
||||
expect(buildForgeBlobUrl("github", { ...input, path: "a.ts" })).toBe(
|
||||
"https://github.com/fork-owner/repo/blob/feature/only-on-fork/a.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the scheme and port of a self-hosted http remote", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("gitea", {
|
||||
remoteUrl: "http://forge.test:3000/team/repo.git",
|
||||
forgeWeb: { host: "forge.test", referenceRepository: "upstream/repo" },
|
||||
sigil: "#",
|
||||
number: 12,
|
||||
}),
|
||||
).toBe("http://forge.test:3000/upstream/repo/issues/12");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,17 +6,23 @@
|
||||
* format); an unknown forge has no grammar and yields null, so the action is
|
||||
* simply absent rather than wrong.
|
||||
*
|
||||
* URL grammar lives on each client forge module. The repo identity and host
|
||||
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
|
||||
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
|
||||
* URL grammar lives on each client forge module. The daemon resolves the web
|
||||
* host (SSH config aliases, cloud SSH hosts) and the repository that owns
|
||||
* issue and change-request numbers (a fork's parent) and publishes them as
|
||||
* `forgeWeb`; branch and file links stay on the origin repository.
|
||||
*/
|
||||
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
|
||||
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import {
|
||||
forgeWebHost,
|
||||
isForgeCloudHost,
|
||||
parseGitRemoteLocation,
|
||||
} from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
|
||||
export interface ForgeBlobUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
branch: string | null | undefined;
|
||||
path: string | null | undefined;
|
||||
lineStart?: number;
|
||||
@@ -25,12 +31,17 @@ export interface ForgeBlobUrlInput {
|
||||
|
||||
export interface ForgeBranchTreeUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
branch: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface ForgeReferenceUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
/** Repository path of a cross-repository reference; the remote's repository when absent. */
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
/**
|
||||
* Repository path of a cross-repository reference; the repository that owns
|
||||
* issue numbers (`forgeWeb.referenceRepository`, else the remote's) when absent.
|
||||
*/
|
||||
repo?: string | null;
|
||||
sigil: ForgeReferenceSigil;
|
||||
number: number;
|
||||
@@ -62,13 +73,6 @@ interface ForgeWebLocation {
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web host + repo path from a remote. The host is the remote's host, except a
|
||||
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
|
||||
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
|
||||
* Self-hosted hosts are returned untouched. The repo path supports nested
|
||||
* groups (e.g. GitLab subgroups) since it is the full remote path.
|
||||
*/
|
||||
function isValidRepoPath(path: string): boolean {
|
||||
const segments = path.split("/").filter((segment) => segment.length > 0);
|
||||
if (segments.length === 0) {
|
||||
@@ -77,9 +81,17 @@ function isValidRepoPath(path: string): boolean {
|
||||
return !segments.includes("..");
|
||||
}
|
||||
|
||||
/**
|
||||
* Web location of the origin repository. The host is the daemon's
|
||||
* `forgeWeb.host`. A daemon without `forgeWeb` publishes none, so the remote's
|
||||
* own host is used, with a cloud SSH alias mapped to the forge's web host.
|
||||
* The repo path supports nested groups (e.g. GitLab subgroups) since it is
|
||||
* the full remote path.
|
||||
*/
|
||||
function resolveForgeWebLocation(
|
||||
forge: string,
|
||||
remoteUrl: string | null | undefined,
|
||||
forgeWeb: ForgeWeb | null | undefined,
|
||||
): ForgeWebLocation | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
@@ -88,9 +100,9 @@ function resolveForgeWebLocation(
|
||||
if (!location || !isValidRepoPath(location.path)) {
|
||||
return null;
|
||||
}
|
||||
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
|
||||
const isCloudHost = cloudHosts.includes(location.host);
|
||||
const webHost = isCloudHost ? cloudHosts[0] : location.host;
|
||||
// COMPAT(forgeWeb): added in v0.16.0, remove the remote-derived host after 2027-06-01 once daemon floor >= v0.16.0.
|
||||
const webHost = forgeWeb?.host ?? forgeWebHost(forge, location.host);
|
||||
const isCloudHost = isForgeCloudHost(forge, location.host);
|
||||
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
|
||||
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
|
||||
// web port, and a canonicalized cloud host always serves on the default port.
|
||||
@@ -139,7 +151,7 @@ export function buildForgeBranchTreeUrl(
|
||||
input: ForgeBranchTreeUrlInput,
|
||||
): string | null {
|
||||
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const branch = input.branch?.trim();
|
||||
if (!grammar || !location || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
@@ -149,7 +161,7 @@ export function buildForgeBranchTreeUrl(
|
||||
|
||||
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
||||
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const branch = input.branch?.trim();
|
||||
const filePath = normalizeBlobPath(input.path);
|
||||
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
|
||||
@@ -175,8 +187,8 @@ export function buildForgeReferenceUrl(
|
||||
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
|
||||
(path) => path.sigil === input.sigil,
|
||||
);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const repo = input.repo ?? location?.repo;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const repo = input.repo ?? input.forgeWeb?.referenceRepository ?? location?.repo;
|
||||
if (
|
||||
!referencePath ||
|
||||
!location ||
|
||||
|
||||
@@ -96,6 +96,8 @@ export interface AppSettings {
|
||||
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
|
||||
sidebarRowItems: SidebarRowItems;
|
||||
sidebarChecksDisplay: SidebarChecksDisplay;
|
||||
/** Plugin workspace row badge items by `<pluginId>/<itemId>`; a missing key shows. */
|
||||
sidebarPluginRowBadgeItems: Record<string, boolean>;
|
||||
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
|
||||
sidebarNavItems: SidebarNavPreference[];
|
||||
/** Sidebar footer items in display order; empty means the default order, all visible. */
|
||||
@@ -156,6 +158,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sidebarWorkspaceTrailing: "timestamp",
|
||||
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||
sidebarPluginRowBadgeItems: {},
|
||||
sidebarNavItems: [],
|
||||
sidebarFooterItems: [],
|
||||
usage: DEFAULT_USAGE_PREFERENCES,
|
||||
@@ -253,6 +256,7 @@ const StoredAppSettingsSchema = z
|
||||
.enum(["iconAndText", "icon", "none"])
|
||||
.optional()
|
||||
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
|
||||
sidebarPluginRowBadgeItems: z.record(z.string(), z.boolean()).catch({}),
|
||||
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
usage: UsagePreferencesSchema,
|
||||
|
||||
@@ -4,20 +4,32 @@ export type KeyboardFocusSurface = "message-input" | "terminal";
|
||||
|
||||
export function createKeyboardFocusScope(hasEditableFocus: () => boolean = () => false) {
|
||||
const surfaces = new Map<symbol, KeyboardFocusSurface>();
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
return {
|
||||
declare(surface: KeyboardFocusSurface) {
|
||||
const token = Symbol(surface);
|
||||
surfaces.set(token, surface);
|
||||
notify();
|
||||
return () => {
|
||||
surfaces.delete(token);
|
||||
if (surfaces.delete(token)) notify();
|
||||
};
|
||||
},
|
||||
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
|
||||
/** Pass `editableFocused` to resolve for a text control that is not a declared surface. */
|
||||
resolve(commandCenterOpen: boolean, editableFocused = hasEditableFocus()): KeyboardFocusScope {
|
||||
if (commandCenterOpen) return "command-center";
|
||||
let focused: KeyboardFocusScope = hasEditableFocus() ? "editable" : "other";
|
||||
let focused: KeyboardFocusScope = editableFocused ? "editable" : "other";
|
||||
for (const surface of surfaces.values()) focused = surface;
|
||||
return focused;
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
export const focusSurfaces = createKeyboardFocusScope(hasFocusedTextInput);
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface KeyboardSourceHandlers {
|
||||
keyUp(event: KeyboardInputEvent): void;
|
||||
blur(): void;
|
||||
commandCenterOpen(): boolean;
|
||||
subscribeCommandCenter(listener: () => void): () => void;
|
||||
}
|
||||
export function nativeKeyboardInput(
|
||||
event: KeyboardInputEvent,
|
||||
@@ -23,5 +24,6 @@ export interface KeyboardPolicyInput {
|
||||
bindings: readonly ParsedShortcutBinding[];
|
||||
chordState?: ChordState;
|
||||
isMac: boolean;
|
||||
usesDesktopBindings: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -366,7 +366,13 @@ describe("Electron guest source through shared resolution", () => {
|
||||
},
|
||||
captureFocusRestore: (id) => restored.push(id),
|
||||
},
|
||||
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
|
||||
{
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
},
|
||||
);
|
||||
receive({
|
||||
browserId: "tab-1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeyboardModule {
|
||||
getHardwareKeyboardPresent(): boolean;
|
||||
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
|
||||
@@ -19,6 +19,9 @@ export function subscribeHardwareKeyboard(
|
||||
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
|
||||
module.setOverlayKeys(keys);
|
||||
}
|
||||
// A focused Android EditText types nothing for Tab, Shift+Tab or Alt+Shift+[,
|
||||
// so there is no character to take from it.
|
||||
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return module.getHardwareKeyboardPresent();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeyboardModule {
|
||||
getHardwareKeyboardPresent(): boolean;
|
||||
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
|
||||
addListener(
|
||||
event: "onHardwareKeyboardShortcut",
|
||||
handler: (event: HardwareKeyboardEvent) => void,
|
||||
@@ -19,6 +20,9 @@ export function subscribeHardwareKeyboard(
|
||||
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
|
||||
module.setOverlayKeys(keys);
|
||||
}
|
||||
export function setHardwareKeyboardClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void {
|
||||
module.setClaimedShortcuts(shortcuts);
|
||||
}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return module.getHardwareKeyboardPresent();
|
||||
|
||||
@@ -3,12 +3,19 @@ export interface HardwareKeyboardEvent extends KeyboardShortcutInput {
|
||||
isComposing: boolean;
|
||||
phase: "down" | "up";
|
||||
}
|
||||
/** A shortcut the native window takes from the focused text control, by DOM `code`. */
|
||||
export interface ClaimedShortcut {
|
||||
code: string;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
export function subscribeHardwareKeyboard(
|
||||
_handler: (event: HardwareKeyboardEvent) => void,
|
||||
): () => void {
|
||||
return () => {};
|
||||
}
|
||||
export function setHardwareKeyboardOverlayKeys(_keys: readonly string[]): void {}
|
||||
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return false;
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { KeyboardSourceHandlers } from "./contract";
|
||||
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
|
||||
import { attachHardwareKeyboardSource } from "./native";
|
||||
import type { KeyboardPolicyInput, KeyboardSourceHandlers } from "./contract";
|
||||
import {
|
||||
setHardwareKeyboardClaimedShortcuts,
|
||||
setHardwareKeyboardOverlayKeys,
|
||||
subscribeHardwareKeyboard,
|
||||
} from "./hardware";
|
||||
import { createHardwareKeyboardSource } from "./native";
|
||||
const hardwareKeyboard = createHardwareKeyboardSource({
|
||||
subscribe: subscribeHardwareKeyboard,
|
||||
setOverlayKeys: setHardwareKeyboardOverlayKeys,
|
||||
setClaimedShortcuts: setHardwareKeyboardClaimedShortcuts,
|
||||
});
|
||||
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
|
||||
return attachHardwareKeyboardSource(
|
||||
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
|
||||
handlers,
|
||||
);
|
||||
return hardwareKeyboard.attach(handlers);
|
||||
}
|
||||
|
||||
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
|
||||
export function publishKeyboardPolicy(input: KeyboardPolicyInput): void {
|
||||
hardwareKeyboard.publishPolicy(input);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { usesDesktopBindings } from "../conventions";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
@@ -26,7 +25,7 @@ export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => v
|
||||
}
|
||||
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
|
||||
const policy = input.enabled
|
||||
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
|
||||
? buildBrowserKeyboardPolicy(input)
|
||||
: { menuPrefixes: [], prefixes: [] };
|
||||
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,92 @@
|
||||
import { matchesKeyboardShortcutContext } from "../../keyboard-shortcuts";
|
||||
import { focusSurfaces } from "../focus-surfaces";
|
||||
import { overlays } from "../overlays";
|
||||
import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
import {
|
||||
nativeKeyboardInput,
|
||||
type KeyboardPolicyInput,
|
||||
type KeyboardSourceHandlers,
|
||||
} from "./contract";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeySource {
|
||||
subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void;
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
|
||||
}
|
||||
export function attachHardwareKeyboardSource(
|
||||
source: HardwareKeySource,
|
||||
handlers: KeyboardSourceHandlers,
|
||||
): () => void {
|
||||
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
|
||||
updateKeys();
|
||||
const unsubscribeOverlays = overlays.subscribe(updateKeys);
|
||||
const unsubscribeHardware = source.subscribe((input) => {
|
||||
const event = { ...input, preventDefault() {}, stopPropagation() {} };
|
||||
if (input.phase === "up") {
|
||||
handlers.keyUp(event);
|
||||
return;
|
||||
|
||||
const DIGIT_CODES = Array.from({ length: 9 }, (_, index) => `Digit${index + 1}`);
|
||||
|
||||
/**
|
||||
* The shortcuts a focused text control would otherwise type or act on.
|
||||
*
|
||||
* The native window observes every press but cannot wait for the pipeline to
|
||||
* decide, so it has to claim these keys up front: without a claim, Shift+Tab
|
||||
* cycles the agent mode and the composer also inserts a tab. Cmd and Ctrl
|
||||
* combos do not type, and the platform's own editing combos stay with the text
|
||||
* control through `editable: false`. Unmodified keys belong to overlays and the
|
||||
* composer's Return.
|
||||
*/
|
||||
function claimedShortcuts(
|
||||
policy: KeyboardPolicyInput | null,
|
||||
commandCenterOpen: boolean,
|
||||
): ClaimedShortcut[] {
|
||||
if (!policy?.enabled) return [];
|
||||
const context = {
|
||||
isMac: policy.isMac,
|
||||
usesDesktopBindings: policy.usesDesktopBindings,
|
||||
commandCenterOpen,
|
||||
focusScope: focusSurfaces.resolve(commandCenterOpen, true),
|
||||
};
|
||||
const claimed = new Map<string, ClaimedShortcut>();
|
||||
for (const binding of policy.bindings) {
|
||||
const combo = binding.parsedChord[0];
|
||||
if (!combo || binding.preventDefault === false) continue;
|
||||
if (combo.meta || combo.ctrl || combo.mod || !(combo.shift || combo.alt)) continue;
|
||||
if (!matchesKeyboardShortcutContext(binding.when, context)) continue;
|
||||
const shift = combo.shift === true;
|
||||
const alt = combo.alt === true;
|
||||
for (const code of combo.code === "Digit" ? DIGIT_CODES : [combo.code]) {
|
||||
claimed.set(`${code}:${shift}:${alt}`, { code, shift, alt });
|
||||
}
|
||||
handlers.keyDown(
|
||||
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeHardware();
|
||||
unsubscribeOverlays();
|
||||
source.setOverlayKeys([]);
|
||||
}
|
||||
return [...claimed.values()];
|
||||
}
|
||||
|
||||
export function createHardwareKeyboardSource(source: HardwareKeySource) {
|
||||
let policy: KeyboardPolicyInput | null = null;
|
||||
let updateClaims = () => {};
|
||||
return {
|
||||
publishPolicy(next: KeyboardPolicyInput) {
|
||||
policy = next;
|
||||
updateClaims();
|
||||
},
|
||||
attach(handlers: KeyboardSourceHandlers): () => void {
|
||||
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
|
||||
updateClaims = () =>
|
||||
source.setClaimedShortcuts(claimedShortcuts(policy, handlers.commandCenterOpen()));
|
||||
updateKeys();
|
||||
updateClaims();
|
||||
const unsubscribeOverlays = overlays.subscribe(updateKeys);
|
||||
const unsubscribeFocus = focusSurfaces.subscribe(() => updateClaims());
|
||||
const unsubscribeCommandCenter = handlers.subscribeCommandCenter(() => updateClaims());
|
||||
const unsubscribeHardware = source.subscribe((input) => {
|
||||
const event = { ...input, preventDefault() {}, stopPropagation() {} };
|
||||
if (input.phase === "up") {
|
||||
handlers.keyUp(event);
|
||||
return;
|
||||
}
|
||||
handlers.keyDown(
|
||||
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeHardware();
|
||||
unsubscribeFocus();
|
||||
unsubscribeCommandCenter();
|
||||
unsubscribeOverlays();
|
||||
updateClaims = () => {};
|
||||
source.setOverlayKeys([]);
|
||||
source.setClaimedShortcuts([]);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("DOM source adapter", () => {
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
|
||||
@@ -130,7 +130,7 @@ describe("hardware source adapter", () => {
|
||||
it.each([true, false])(
|
||||
"delivers hardware down/up and declared focus with mac=%s",
|
||||
async (isMac) => {
|
||||
const { attachHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { focusSurfaces } = await import("./internal/focus-surfaces");
|
||||
const actions: string[] = [];
|
||||
const releases: string[] = [];
|
||||
@@ -148,23 +148,22 @@ describe("hardware source adapter", () => {
|
||||
},
|
||||
overlays: createOverlayRegistry(),
|
||||
});
|
||||
const detach = attachHardwareKeyboardSource(
|
||||
{
|
||||
subscribe(handler) {
|
||||
events.add(handler);
|
||||
return () => {
|
||||
events.delete(handler);
|
||||
};
|
||||
},
|
||||
setOverlayKeys() {},
|
||||
const detach = createHardwareKeyboardSource({
|
||||
subscribe(handler) {
|
||||
events.add(handler);
|
||||
return () => {
|
||||
events.delete(handler);
|
||||
};
|
||||
},
|
||||
{
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: (event) => releases.push(event.key),
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
},
|
||||
);
|
||||
setOverlayKeys() {},
|
||||
setClaimedShortcuts() {},
|
||||
}).attach({
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: (event) => releases.push(event.key),
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const down = {
|
||||
...keyboardEvent("k", "KeyK", isMac),
|
||||
isComposing: false,
|
||||
@@ -180,4 +179,94 @@ describe("hardware source adapter", () => {
|
||||
expect(events.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("claims the Shift and Option shortcuts a focused text control would otherwise type", async () => {
|
||||
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { focusSurfaces } = await import("./internal/focus-surfaces");
|
||||
let claimed: string[] = [];
|
||||
const source = createHardwareKeyboardSource({
|
||||
subscribe: () => () => {},
|
||||
setOverlayKeys() {},
|
||||
setClaimedShortcuts(shortcuts) {
|
||||
claimed = shortcuts
|
||||
.map(({ code, shift, alt }) => [alt && "Alt", shift && "Shift", code])
|
||||
.map((parts) => parts.filter(Boolean).join("+"))
|
||||
.sort();
|
||||
},
|
||||
});
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
let commandCenterOpen = false;
|
||||
const commandCenterListeners = new Set<() => void>();
|
||||
const detach = source.attach({
|
||||
keyDown() {},
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => commandCenterOpen,
|
||||
subscribeCommandCenter(listener) {
|
||||
commandCenterListeners.add(listener);
|
||||
return () => commandCenterListeners.delete(listener);
|
||||
},
|
||||
});
|
||||
const setCommandCenterOpen = (open: boolean) => {
|
||||
commandCenterOpen = open;
|
||||
for (const listener of commandCenterListeners) listener();
|
||||
};
|
||||
// iPad tab switching uses Cmd+Shift+[ / ], which no text control types.
|
||||
expect(claimed).toEqual([]);
|
||||
|
||||
const releaseComposer = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
setCommandCenterOpen(true);
|
||||
expect(claimed).toEqual([]);
|
||||
setCommandCenterOpen(false);
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
releaseComposer();
|
||||
expect(claimed).toEqual([]);
|
||||
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Alt+M" }),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
const releaseRebound = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual(["Alt+KeyM"]);
|
||||
releaseRebound();
|
||||
|
||||
// A chord's later step is only a shortcut while the chord is in progress;
|
||||
// claiming it always would stop the composer typing a capital A.
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Mod+J Shift+A" }),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
const releaseChord = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual([]);
|
||||
releaseChord();
|
||||
|
||||
const releaseFinal = focusSurfaces.declare("message-input");
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: false,
|
||||
});
|
||||
expect(claimed).toEqual([]);
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
detach();
|
||||
expect(claimed).toEqual([]);
|
||||
releaseFinal();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useKeyboardShortcuts({
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||
const publishBrowserShortcutPolicy = useCallback(
|
||||
(chordState?: ChordState) => {
|
||||
publishKeyboardPolicy({ bindings, chordState, isMac, enabled });
|
||||
publishKeyboardPolicy({ bindings, chordState, isMac, usesDesktopBindings, enabled });
|
||||
},
|
||||
[bindings, enabled, isMac],
|
||||
);
|
||||
@@ -265,6 +265,10 @@ export function useKeyboardShortcuts({
|
||||
keyUp: handleKeyUp,
|
||||
blur: resetModifiers,
|
||||
commandCenterOpen: () => useKeyboardShortcutsStore.getState().commandCenterOpen,
|
||||
subscribeCommandCenter: (listener) =>
|
||||
useKeyboardShortcutsStore.subscribe((state, previous) => {
|
||||
if (state.commandCenterOpen !== previous.commandCenterOpen) listener();
|
||||
}),
|
||||
});
|
||||
return () => {
|
||||
detach();
|
||||
|
||||
@@ -134,6 +134,7 @@ describe("DOM source through the shared resolver", () => {
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
|
||||
@@ -26,6 +26,7 @@ function installation(): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -105,6 +105,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -37,6 +37,7 @@ function installed(): InstalledPlugin {
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -47,6 +47,7 @@ function plugin(): InstalledPlugin {
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -101,6 +101,7 @@ describe("evaluatePluginClientBundle", () => {
|
||||
plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }),
|
||||
plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component }),
|
||||
plugin.addSidebarFooterItem({ id: "status", title: "Status", Component }),
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Status", icon: "Blocks", Component }),
|
||||
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
|
||||
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
|
||||
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
|
||||
@@ -132,6 +133,7 @@ describe("evaluatePluginClientBundle", () => {
|
||||
plugin.settingsScreens,
|
||||
plugin.sidebarItems.header,
|
||||
plugin.sidebarItems.footer,
|
||||
plugin.workspaceRowBadgeItems,
|
||||
plugin.workspacePanels,
|
||||
plugin.commandCenterItems,
|
||||
plugin.clientSlashCommands,
|
||||
@@ -152,6 +154,7 @@ describe("evaluatePluginClientBundle", () => {
|
||||
plugin.settingsScreens,
|
||||
plugin.sidebarItems.header,
|
||||
plugin.sidebarItems.footer,
|
||||
plugin.workspaceRowBadgeItems,
|
||||
plugin.workspacePanels,
|
||||
plugin.commandCenterItems,
|
||||
plugin.clientSlashCommands,
|
||||
@@ -282,6 +285,44 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toThrow("Sidebar item main has no title");
|
||||
});
|
||||
|
||||
it("collects workspace row badge items and rejects duplicate and malformed ones", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
function Item() { return null; }
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: " Status ", icon: "Blocks", Component: Item });
|
||||
`),
|
||||
);
|
||||
expect(
|
||||
plugin.workspaceRowBadgeItems.map(({ id, title, icon }) => ({ id, title, icon })),
|
||||
).toEqual([{ id: "status", title: "Status", icon: "Blocks" }]);
|
||||
|
||||
const row = (fields: string) =>
|
||||
bundle(`function Item() { return null; } plugin.addWorkspaceRowBadgeItem({ ${fields} });`);
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
function Item() { return null; }
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "One", icon: "Blocks", Component: Item });
|
||||
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Two", icon: "Blocks", Component: Item });
|
||||
`),
|
||||
),
|
||||
).toThrow("Duplicate workspace row badge item: status");
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
row(`id: "status", title: " ", icon: "Blocks", Component: Item`),
|
||||
),
|
||||
).toThrow("Workspace row badge item status has no title");
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
row(`id: "status", title: "Status", icon: "Blocks", Component: "Item"`),
|
||||
),
|
||||
).toThrow("Workspace row badge item status is not a component");
|
||||
});
|
||||
|
||||
it("expands the addSurface and addSidebarItem aliases to a screen and a legacy header item", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
PluginScreenContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginWorkspacePanelContribution,
|
||||
PluginWorkspaceRowBadgeItemContribution,
|
||||
} from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
|
||||
@@ -133,6 +134,7 @@ export function runPluginClientBundle(
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
@@ -151,6 +153,7 @@ export function runPluginClientBundle(
|
||||
header: new Set(),
|
||||
footer: new Set(),
|
||||
};
|
||||
const workspaceRowItemIds = new Set<string>();
|
||||
const filePanelIds = new Set<string>();
|
||||
const composerItemIds = new Set<string>();
|
||||
const newWorkspaceItemIds = new Set<string>();
|
||||
@@ -323,6 +326,23 @@ export function runPluginClientBundle(
|
||||
addSidebarFooterItem(contribution: PluginSidebarItemContribution) {
|
||||
return addSidebarItem("footer", contribution);
|
||||
},
|
||||
addWorkspaceRowBadgeItem(contribution: PluginWorkspaceRowBadgeItemContribution) {
|
||||
const itemId = requireId(contribution.id, "workspace row badge item id");
|
||||
if (workspaceRowItemIds.has(itemId))
|
||||
throw new Error(`Duplicate workspace row badge item: ${itemId}`);
|
||||
const title = normalizePluginText(contribution.title);
|
||||
if (!title) throw new Error(`Workspace row badge item ${itemId} has no title`);
|
||||
const icon = parsePluginIcon(`Workspace row badge item ${itemId}`, contribution.icon);
|
||||
if (!isComponentType(contribution.Component)) {
|
||||
throw new Error(`Workspace row badge item ${itemId} is not a component`);
|
||||
}
|
||||
workspaceRowItemIds.add(itemId);
|
||||
return register(
|
||||
collector.workspaceRowBadgeItems,
|
||||
{ id: itemId, title, icon, Component: contribution.Component },
|
||||
() => workspaceRowItemIds.delete(itemId),
|
||||
);
|
||||
},
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
// Titled by its id; a legacy item pointing at it lends the header its own title instead.
|
||||
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
|
||||
@@ -634,6 +654,7 @@ export function runPluginClientBundle(
|
||||
sidebarViews: collector.sidebarViews,
|
||||
sidebarItems: collector.sidebarItems,
|
||||
legacySidebarItems: collector.legacySidebarItems,
|
||||
workspaceRowBadgeItems: collector.workspaceRowBadgeItems,
|
||||
filePanels: collector.filePanels,
|
||||
composerItems: collector.composerItems,
|
||||
newWorkspaceItems: collector.newWorkspaceItems,
|
||||
|
||||
@@ -46,6 +46,7 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { InstalledPlugin } from "./types";
|
||||
/** `withUnistyles` mapping that hands a host the plugin theme. */
|
||||
export const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
|
||||
function renderNothing() {
|
||||
export function renderNothing() {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
export { ExternalLink } from "@/components/ui/external-link";
|
||||
|
||||
export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
|
||||
export { WorkspaceRowBadge } from "@/plugins/workspace-row-badge-items/kit";
|
||||
|
||||
export { DocumentFrame } from "@/file-panels/document-frame";
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ export class PluginRegistry {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -37,6 +37,7 @@ function installed(
|
||||
footer: [{ id: "status", title: "Status", Component: () => null }],
|
||||
},
|
||||
legacySidebarItems,
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -51,6 +51,7 @@ function installation(
|
||||
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
workspaceRowBadgeItems: [],
|
||||
legacySidebarItems: sidebarItems.map((item) => ({
|
||||
...item,
|
||||
title: item.id,
|
||||
|
||||
@@ -64,6 +64,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -27,6 +27,7 @@ function plugin(input: {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("../client-runtime", () => ({
|
||||
}));
|
||||
vi.mock("../icons", () => ({
|
||||
Icon: () => null,
|
||||
PluginIconView: () => null,
|
||||
pluginIconComponent: () => () => null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
PluginSidebarContribution as SDKPluginSidebarContribution,
|
||||
PluginSidebarViewContribution,
|
||||
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
|
||||
PluginWorkspaceRowBadgeItemContribution as SDKPluginWorkspaceRowBadgeItemContribution,
|
||||
PluginScreenContribution as SDKPluginScreenContribution,
|
||||
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
|
||||
PluginTimelineRendererContribution,
|
||||
@@ -33,6 +34,8 @@ type PluginClientSlashCommandContribution = SDKPluginClientSlashCommandContribut
|
||||
type PluginComposerPillContribution = SDKPluginComposerPillContribution<PluginText>;
|
||||
type PluginSidebarContribution = SDKPluginSidebarContribution<PluginText>;
|
||||
type PluginSidebarItemContribution = SDKPluginSidebarItemContribution<PluginText>;
|
||||
type PluginWorkspaceRowBadgeItemContribution =
|
||||
SDKPluginWorkspaceRowBadgeItemContribution<PluginText>;
|
||||
type PluginScreenContribution = SDKPluginScreenContribution<PluginText>;
|
||||
type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution<PluginText>;
|
||||
type WorkspacePanelInput = SDKPluginWorkspacePanelContribution<PluginText>;
|
||||
@@ -56,6 +59,7 @@ export interface EvaluatedPlugin {
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
|
||||
legacySidebarItems: PluginSidebarContribution[];
|
||||
workspaceRowBadgeItems: PluginWorkspaceRowBadgeItemContribution[];
|
||||
filePanels: PluginFilePanelContribution[];
|
||||
composerItems: PluginComposerItemContribution[];
|
||||
newWorkspaceItems: PluginNewWorkspaceItemContribution[];
|
||||
@@ -95,6 +99,7 @@ export type {
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginWorkspaceRowBadgeItemContribution,
|
||||
PluginScreenContribution,
|
||||
PluginSettingsScreenContribution,
|
||||
PluginThemeContribution,
|
||||
|
||||
@@ -24,6 +24,7 @@ function installed(): InstalledPlugin {
|
||||
sidebarViews: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspaceRowBadgeItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import type { PluginWorkspaceRowBadgeItemProps } from "@getpaseo/plugin/client";
|
||||
import { useMemo } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { pluginThemeMapping, renderNothing, usePluginItemHost } from "../item-host";
|
||||
import { PluginEnvironmentProvider } from "../popover";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import type { InstalledPlugin, PluginWorkspaceRowBadgeItemContribution } from "../types";
|
||||
import { isWorkspaceRowBadgeItemVisible, workspaceRowBadgeItemKey } from "./model";
|
||||
|
||||
import { WorkspaceRowBadgeTitleContext } from "./kit";
|
||||
|
||||
export { WorkspaceRowBadge } from "./kit";
|
||||
export {
|
||||
groupPluginWorkspaceRowBadgeItems,
|
||||
isWorkspaceRowBadgeItemVisible,
|
||||
type PluginWorkspaceRowBadgeItemGroup,
|
||||
} from "./model";
|
||||
|
||||
interface RowBadgeItemTarget {
|
||||
plugin: InstalledPlugin;
|
||||
item: PluginWorkspaceRowBadgeItemContribution;
|
||||
}
|
||||
|
||||
/** The visible plugin row items installed on this workspace's host. */
|
||||
export function usePluginWorkspaceRowBadgeItems(serverId: string | null): RowBadgeItemTarget[] {
|
||||
const installed = useInstalledPlugins();
|
||||
const {
|
||||
settings: { sidebarPluginRowBadgeItems },
|
||||
} = useAppSettings();
|
||||
return useMemo(
|
||||
() =>
|
||||
installed
|
||||
.filter((plugin) => plugin.serverId === serverId)
|
||||
.flatMap((plugin) => plugin.workspaceRowBadgeItems.map((item) => ({ plugin, item })))
|
||||
.filter(({ plugin, item }) =>
|
||||
isWorkspaceRowBadgeItemVisible(
|
||||
sidebarPluginRowBadgeItems,
|
||||
workspaceRowBadgeItemKey(plugin.id, item.id),
|
||||
),
|
||||
),
|
||||
[installed, serverId, sidebarPluginRowBadgeItems],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin items on a workspace's meta line, after the row's own. No wrapper views: an item that
|
||||
* renders nothing must leave no gap behind it on the line.
|
||||
*/
|
||||
export function PluginWorkspaceRowBadgeItems({
|
||||
targets,
|
||||
workspaceId,
|
||||
}: {
|
||||
targets: readonly RowBadgeItemTarget[];
|
||||
workspaceId: string;
|
||||
}) {
|
||||
return targets.map((target) => (
|
||||
<ThemedRowBadgeItemHost
|
||||
key={workspaceRowBadgeItemKey(target.plugin.id, target.item.id)}
|
||||
target={target}
|
||||
workspaceId={workspaceId}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
function RowBadgeItemHost({
|
||||
target: { plugin, item },
|
||||
workspaceId,
|
||||
theme,
|
||||
}: {
|
||||
target: RowBadgeItemTarget;
|
||||
workspaceId: string;
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
const { environment, props: hostProps } = usePluginItemHost(plugin, theme);
|
||||
const props = useMemo<PluginWorkspaceRowBadgeItemProps>(
|
||||
() => ({ ...hostProps, workspaceId }),
|
||||
[hostProps, workspaceId],
|
||||
);
|
||||
const title = plugin.i18n.resolve(item.title, plugin.locale);
|
||||
const Item = item.Component;
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Item} renderError={renderNothing}>
|
||||
<PluginEnvironmentProvider environment={environment}>
|
||||
<WorkspaceRowBadgeTitleContext.Provider value={title}>
|
||||
<Item {...props} />
|
||||
</WorkspaceRowBadgeTitleContext.Provider>
|
||||
</PluginEnvironmentProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedRowBadgeItemHost = withUnistyles(RowBadgeItemHost);
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { WorkspaceRowBadgeProps, WorkspaceRowBadgeTone } from "@getpaseo/plugin/client/ui";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { parsePluginIcon, PluginIconView } from "../icons";
|
||||
|
||||
/** The item's title, the accessible name of a badge that has neither a label nor its own. */
|
||||
export const WorkspaceRowBadgeTitleContext = createContext<string | undefined>(undefined);
|
||||
|
||||
const ThemedPluginIconView = withUnistyles(PluginIconView);
|
||||
|
||||
function toneColor(theme: Theme, tone: WorkspaceRowBadgeTone): string {
|
||||
if (tone === "success") return theme.colors.statusSuccess;
|
||||
if (tone === "warning") return theme.colors.statusWarning;
|
||||
if (tone === "danger") return theme.colors.statusDanger;
|
||||
if (tone === "info") return theme.colors.statusDotRunning;
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
|
||||
const TONE_MAPPINGS: Record<WorkspaceRowBadgeTone, (theme: Theme) => { color: string }> = {
|
||||
muted: (theme) => ({ color: toneColor(theme, "muted") }),
|
||||
success: (theme) => ({ color: toneColor(theme, "success") }),
|
||||
warning: (theme) => ({ color: toneColor(theme, "warning") }),
|
||||
danger: (theme) => ({ color: toneColor(theme, "danger") }),
|
||||
info: (theme) => ({ color: toneColor(theme, "info") }),
|
||||
};
|
||||
|
||||
/**
|
||||
* A plugin's badge on the line under a workspace title, drawn like the row's own peers: one glyph
|
||||
* at the line's size and a short word, in the color of the state it reports. The plugin owns the
|
||||
* slot, so the badge draws no separator.
|
||||
*/
|
||||
export function WorkspaceRowBadge({
|
||||
icon,
|
||||
label,
|
||||
tone = "muted",
|
||||
accessibilityLabel,
|
||||
}: WorkspaceRowBadgeProps) {
|
||||
const itemTitle = useContext(WorkspaceRowBadgeTitleContext);
|
||||
// Parsed at render, not registration, so an invalid icon throws to the item's boundary.
|
||||
const parsedIcon = useMemo(
|
||||
() => (icon === undefined ? null : parsePluginIcon("Workspace row badge", icon)),
|
||||
[icon],
|
||||
);
|
||||
if (!parsedIcon && !label) return null;
|
||||
return (
|
||||
<View style={styles.item} accessibilityLabel={accessibilityLabel ?? (label || itemTitle)}>
|
||||
{parsedIcon ? (
|
||||
<ThemedPluginIconView
|
||||
icon={parsedIcon}
|
||||
size={HOST_BADGE_ICON_SIZE}
|
||||
uniProps={TONE_MAPPINGS[tone]}
|
||||
/>
|
||||
) : null}
|
||||
{label ? (
|
||||
<Text style={[styles.text, textToneStyle(tone)]} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Read inside render, never into a module-scope table — see docs/unistyles.md.
|
||||
function textToneStyle(tone: WorkspaceRowBadgeTone) {
|
||||
if (tone === "success") return styles.success;
|
||||
if (tone === "warning") return styles.warning;
|
||||
if (tone === "danger") return styles.danger;
|
||||
if (tone === "info") return styles.info;
|
||||
return styles.muted;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 16,
|
||||
flexShrink: 1,
|
||||
},
|
||||
muted: { color: toneColor(theme, "muted") },
|
||||
success: { color: toneColor(theme, "success") },
|
||||
warning: { color: toneColor(theme, "warning") },
|
||||
danger: { color: toneColor(theme, "danger") },
|
||||
info: { color: toneColor(theme, "info") },
|
||||
}));
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { groupPluginWorkspaceRowBadgeItems, isWorkspaceRowBadgeItemVisible } from "./model";
|
||||
|
||||
function Item() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function installed(id: string, serverId: string, itemIds: string[]): InstalledPlugin {
|
||||
return {
|
||||
id,
|
||||
serverId,
|
||||
locale: "en",
|
||||
i18n: { resolve: (text: string) => text },
|
||||
workspaceRowBadgeItems: itemIds.map((itemId) => ({
|
||||
id: itemId,
|
||||
title: `${id} ${itemId}`,
|
||||
icon: "Gauge",
|
||||
Component: Item,
|
||||
})),
|
||||
} as unknown as InstalledPlugin;
|
||||
}
|
||||
|
||||
describe("groupPluginWorkspaceRowBadgeItems", () => {
|
||||
it("lists each plugin item once across hosts, in install order", () => {
|
||||
const groups = groupPluginWorkspaceRowBadgeItems([
|
||||
installed("status", "host-a", ["state"]),
|
||||
installed("asks", "host-a", ["open"]),
|
||||
installed("status", "host-b", ["state"]),
|
||||
]);
|
||||
expect(groups.map(({ key, title }) => ({ key, title }))).toEqual([
|
||||
{ key: "status/state", title: "status state" },
|
||||
{ key: "asks/open", title: "asks open" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWorkspaceRowBadgeItemVisible", () => {
|
||||
it("shows items until they are switched off", () => {
|
||||
expect(isWorkspaceRowBadgeItemVisible({}, "status/state")).toBe(true);
|
||||
expect(isWorkspaceRowBadgeItemVisible({ "status/state": true }, "status/state")).toBe(true);
|
||||
expect(isWorkspaceRowBadgeItemVisible({ "status/state": false }, "status/state")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { PluginIcon } from "@getpaseo/plugin/client";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
|
||||
/** One workspace row badge item, coalesced across every host that contributes it. */
|
||||
export interface PluginWorkspaceRowBadgeItemGroup {
|
||||
/** `<pluginId>/<itemId>`: the key its Show toggle is stored under. */
|
||||
key: string;
|
||||
pluginId: string;
|
||||
itemId: string;
|
||||
title: string;
|
||||
icon: PluginIcon;
|
||||
}
|
||||
|
||||
export function workspaceRowBadgeItemKey(pluginId: string, itemId: string): string {
|
||||
return `${pluginId}/${itemId}`;
|
||||
}
|
||||
|
||||
/** An item missing from the stored record has never been toggled, so it shows. */
|
||||
export function isWorkspaceRowBadgeItemVisible(
|
||||
visibility: Readonly<Record<string, boolean>>,
|
||||
key: string,
|
||||
): boolean {
|
||||
return visibility[key] !== false;
|
||||
}
|
||||
|
||||
/** Distinct items in install order, for the Show menu. Each host's copy draws on its own rows. */
|
||||
export function groupPluginWorkspaceRowBadgeItems(
|
||||
plugins: readonly InstalledPlugin[],
|
||||
): PluginWorkspaceRowBadgeItemGroup[] {
|
||||
const groups = new Map<string, PluginWorkspaceRowBadgeItemGroup>();
|
||||
for (const plugin of plugins) {
|
||||
for (const item of plugin.workspaceRowBadgeItems) {
|
||||
const key = workspaceRowBadgeItemKey(plugin.id, item.id);
|
||||
if (groups.has(key)) continue;
|
||||
groups.set(key, {
|
||||
key,
|
||||
pluginId: plugin.id,
|
||||
itemId: item.id,
|
||||
title: plugin.i18n.resolve(item.title, plugin.locale),
|
||||
icon: item.icon,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
@@ -354,6 +354,21 @@ describe("ReplicaCache", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves where the workspace's forge is on the web across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } });
|
||||
const forgeWeb = { host: "github.com", referenceRepository: "main-owner/repo" };
|
||||
const workspace = normalizeWorkspaceDescriptor({ ...workspacePayload(), forgeWeb });
|
||||
baseline.workspaces.set(workspace.id, workspace);
|
||||
commitDirectory(writer, SERVER_ID, baseline);
|
||||
await writer.flush();
|
||||
|
||||
const restored = await createCache(storage).readDirectory(SERVER_ID);
|
||||
|
||||
expect(restored.workspaces.get(workspace.id)?.forgeWeb).toEqual(forgeWeb);
|
||||
});
|
||||
|
||||
it("preserves workspace sorting timestamps across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import {
|
||||
AgentStatusSchema,
|
||||
AgentTimelineItemPayloadSchema,
|
||||
ForgeWebSchema,
|
||||
WorkspaceGitHubRuntimePayloadSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
|
||||
@@ -335,6 +336,7 @@ const StoredWorkspaceSchema = z.strictObject({
|
||||
gitRuntime: WorkspaceGitRuntimeSchema,
|
||||
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
|
||||
forge: z.string().optional(),
|
||||
forgeWeb: ForgeWebSchema.optional(),
|
||||
});
|
||||
|
||||
const StoredProjectSchema = z.strictObject({
|
||||
@@ -768,6 +770,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
|
||||
gitRuntime: workspace.gitRuntime,
|
||||
githubRuntime: workspace.githubRuntime,
|
||||
forge: workspace.forge,
|
||||
forgeWeb: workspace.forgeWeb,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3919,7 +3919,11 @@ function WorkspaceMarkdownLinks({
|
||||
}: AssistantFileLinkResolverProviderProps & { workspace: WorkspaceDescriptor | null | undefined }) {
|
||||
return (
|
||||
<AssistantFileLinkResolverProvider {...fileLinks}>
|
||||
<ForgeShorthandProvider forge={workspace?.forge} remoteUrl={workspace?.gitRuntime?.remoteUrl}>
|
||||
<ForgeShorthandProvider
|
||||
forge={workspace?.forge}
|
||||
remoteUrl={workspace?.gitRuntime?.remoteUrl}
|
||||
forgeWeb={workspace?.forgeWeb}
|
||||
>
|
||||
{children}
|
||||
</ForgeShorthandProvider>
|
||||
</AssistantFileLinkResolverProvider>
|
||||
|
||||
@@ -140,6 +140,7 @@ export interface WorkspaceDescriptor {
|
||||
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
|
||||
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
|
||||
forge?: WorkspaceDescriptorPayload["forge"];
|
||||
forgeWeb?: WorkspaceDescriptorPayload["forgeWeb"];
|
||||
project?: ProjectPlacementPayload;
|
||||
}
|
||||
|
||||
@@ -185,6 +186,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
gitRuntime: payload.gitRuntime,
|
||||
githubRuntime: payload.githubRuntime,
|
||||
forge: payload.forge,
|
||||
forgeWeb: payload.forgeWeb,
|
||||
project: payload.project,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ import type { TFunction } from "i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
|
||||
import type {
|
||||
AvailableUsageReport,
|
||||
UsageReport,
|
||||
UsageReportEntry,
|
||||
UsageView,
|
||||
UsageWindow,
|
||||
} from "./types";
|
||||
|
||||
export function usedPercent(window: UsageWindow): number | null {
|
||||
if (window.usedPct != null) return window.usedPct;
|
||||
@@ -205,13 +211,34 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null
|
||||
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
|
||||
}
|
||||
|
||||
export interface UsageSnapshot {
|
||||
report: AvailableUsageReport;
|
||||
successfulAt: string;
|
||||
retained: boolean;
|
||||
}
|
||||
|
||||
/** The latest successful values and their freshness, independent of the latest attempt's outcome. */
|
||||
export function resolveUsageSnapshot(entry: UsageReportEntry): UsageSnapshot | null {
|
||||
if (entry.report.status === "available") {
|
||||
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Old daemons expose only attempt time for available reports, never for failures.
|
||||
return {
|
||||
report: entry.report,
|
||||
successfulAt: entry.successfulAt ?? entry.fetchedAt,
|
||||
retained: false,
|
||||
};
|
||||
}
|
||||
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Older daemons cannot provide history; a failed attempt is never a successful update.
|
||||
const retained = entry.lastSuccessful;
|
||||
if (!retained) return null;
|
||||
return { report: retained.report, successfulAt: retained.fetchedAt, retained: true };
|
||||
}
|
||||
|
||||
/** Current outcome owns messages; only a successful poll owns values and freshness. */
|
||||
export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
|
||||
const available = entry.report.status === "available";
|
||||
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Older daemons cannot provide history; a failed attempt is never a successful update.
|
||||
const snapshot = available ? null : (entry.lastSuccessful ?? null);
|
||||
const content = entry.report.status === "available" ? entry.report : (snapshot?.report ?? null);
|
||||
const snapshot = resolveUsageSnapshot(entry);
|
||||
const messages: string[] = [];
|
||||
if (!available) {
|
||||
if (entry.loginErrors)
|
||||
@@ -224,16 +251,12 @@ export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
|
||||
else messages.push(usageProblemText(entry.report, t));
|
||||
}
|
||||
return {
|
||||
windows: content?.windows ?? [],
|
||||
balances: content?.balances ?? [],
|
||||
details: content?.details ?? [],
|
||||
planLabel: content?.planLabel ?? null,
|
||||
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Old daemons expose only attempt time for available reports, never for failures.
|
||||
successfulAt: available
|
||||
? (entry.successfulAt ?? entry.fetchedAt)
|
||||
: (snapshot?.fetchedAt ?? null),
|
||||
retained: snapshot !== null,
|
||||
windows: snapshot?.report.windows ?? [],
|
||||
balances: snapshot?.report.balances ?? [],
|
||||
details: snapshot?.report.details ?? [],
|
||||
planLabel: snapshot?.report.planLabel ?? null,
|
||||
successfulAt: snapshot?.successfulAt ?? null,
|
||||
retained: snapshot?.retained ?? false,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ describe("resolvePinnedUsage", () => {
|
||||
expect(sources[0]).toEqual({
|
||||
key: "claude:default",
|
||||
icon: "<svg/>",
|
||||
hasError: false,
|
||||
windows: [
|
||||
{
|
||||
key: "claude:default/five-hour",
|
||||
@@ -257,7 +258,7 @@ describe("resolvePinnedUsage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unavailable and error reports out of the sidebar summary even with retained data", () => {
|
||||
it("shows retained windows and marks errored sources in the sidebar summary", () => {
|
||||
if (claude.report.status !== "available" || codex.report.status !== "available")
|
||||
throw new Error("fixtures must be available");
|
||||
const expired: UsageReportEntry = {
|
||||
@@ -273,7 +274,30 @@ it("keeps unavailable and error reports out of the sidebar summary even with ret
|
||||
lastSuccessful: { fetchedAt: codex.fetchedAt, report: codex.report },
|
||||
report: { status: "error", error: "Store deleted" },
|
||||
};
|
||||
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
|
||||
const expected = [
|
||||
{ key: "claude:default", hasError: false, windows: ["31%"] },
|
||||
{ key: "codex:default", hasError: true, windows: ["12%"] },
|
||||
];
|
||||
const summarize = (pins: UsagePreferences["pins"]) =>
|
||||
resolvePinnedUsage([expired, failed], preferences(pins)).map((source) => ({
|
||||
key: source.key,
|
||||
hasError: source.hasError,
|
||||
windows: source.windows.map((window) => window.percentText),
|
||||
}));
|
||||
|
||||
expect(summarize(null)).toEqual(expected);
|
||||
expect(
|
||||
summarize([
|
||||
{ sourceId: "claude", windowId: "five-hour" },
|
||||
{ sourceId: "codex", windowId: "weekly" },
|
||||
]),
|
||||
).toEqual(expected);
|
||||
expect(
|
||||
resolvePinnedUsage(
|
||||
[{ ...failed, lastSuccessful: undefined }],
|
||||
preferences([{ sourceId: "codex", windowId: "weekly" }]),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("localizes pinned labels and meanings while preserving custom window names", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format";
|
||||
import { displayPercent } from "./model";
|
||||
import { displayPercent, resolveUsageSnapshot } from "./model";
|
||||
import { effectiveUsagePins, type UsagePreferences } from "./preferences";
|
||||
import { windowTone } from "./tone";
|
||||
import type { UsageReportEntry, UsageTone } from "./types";
|
||||
@@ -23,6 +23,8 @@ export interface PinnedUsageWindow {
|
||||
export interface PinnedUsageSource {
|
||||
key: string;
|
||||
icon: string | null;
|
||||
/** The displayed values are retained from before a failed refresh. */
|
||||
hasError: boolean;
|
||||
windows: PinnedUsageWindow[];
|
||||
}
|
||||
|
||||
@@ -74,8 +76,9 @@ export function resolvePinnedUsage(
|
||||
): PinnedUsageSource[] {
|
||||
const pins = effectiveUsagePins(preferences, reports);
|
||||
return groupBySource(reports).flatMap((entry) => {
|
||||
if (entry.report.status !== "available") return [];
|
||||
const windows = entry.report.windows
|
||||
const snapshot = resolveUsageSnapshot(entry);
|
||||
if (!snapshot) return [];
|
||||
const windows = snapshot.report.windows
|
||||
.filter(
|
||||
(window) =>
|
||||
displayPercent(window, preferences.displayAs) !== null &&
|
||||
@@ -94,6 +97,13 @@ export function resolvePinnedUsage(
|
||||
};
|
||||
});
|
||||
if (windows.length === 0) return [];
|
||||
return [{ key: entry.id, icon: entry.icon ?? null, windows }];
|
||||
return [
|
||||
{
|
||||
key: entry.id,
|
||||
icon: entry.icon ?? null,
|
||||
hasError: entry.report.status === "error",
|
||||
windows,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { displayPercent } from "./model";
|
||||
import { displayPercent, resolveUsageSnapshot } from "./model";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
|
||||
/** Whether percentages read as the share used or the share left. */
|
||||
@@ -55,8 +55,9 @@ export function effectiveUsagePins(
|
||||
if (preferences.pins !== null) return preferences.pins;
|
||||
const pins: UsagePin[] = [];
|
||||
for (const entry of reports) {
|
||||
if (entry.report.status !== "available") continue;
|
||||
const withPercent = entry.report.windows.filter(
|
||||
const snapshot = resolveUsageSnapshot(entry);
|
||||
if (!snapshot) continue;
|
||||
const withPercent = snapshot.report.windows.filter(
|
||||
(window) => displayPercent(window, preferences.displayAs) !== null,
|
||||
);
|
||||
const marked = withPercent.filter((window) => window.summary);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TriangleAlert } from "lucide-react-native";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
@@ -15,7 +16,8 @@ import {
|
||||
type LayoutChangeEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
@@ -35,6 +37,9 @@ import {
|
||||
} from "./pinned";
|
||||
import { UsageModal } from "./usage-modal";
|
||||
|
||||
const ThemedTriangleAlert = withUnistyles(TriangleAlert);
|
||||
const errorIconColor = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
|
||||
/** Each summary window with data on the usage host, under its source; empty while none has. */
|
||||
export function useUsageSummary(): readonly PinnedUsageSource[] {
|
||||
const { t } = useTranslation();
|
||||
@@ -60,7 +65,14 @@ export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSou
|
||||
const label = t(builtinSidebarNavLabelKey("usage"));
|
||||
const openUsage = useOpenSidebarUsage();
|
||||
if (sources.length === 0) return null;
|
||||
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
|
||||
return (
|
||||
<PinnedUsageTrigger
|
||||
label={label}
|
||||
sources={sources}
|
||||
errorLabel={t("providerUsage.refreshFailed")}
|
||||
onPress={openUsage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const OpenSidebarUsageContext = createContext<(() => void) | null>(null);
|
||||
@@ -95,9 +107,15 @@ export function useOpenSidebarUsage(): () => void {
|
||||
return openUsage;
|
||||
}
|
||||
|
||||
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
|
||||
function pinnedUsageLabel(
|
||||
label: string,
|
||||
sources: readonly PinnedUsageSource[],
|
||||
errorLabel: string,
|
||||
): string {
|
||||
const windows = sources.flatMap((source) => source.windows);
|
||||
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
|
||||
const summary = windows.map((window) => window.label).join(", ");
|
||||
const error = sources.some((source) => source.hasError) ? `, ${errorLabel}` : "";
|
||||
return `${label}: ${summary}${error}`;
|
||||
}
|
||||
|
||||
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
@@ -116,10 +134,12 @@ function layoutWidth(event: LayoutChangeEvent): number {
|
||||
function PinnedUsageTrigger({
|
||||
label,
|
||||
sources,
|
||||
errorLabel,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
sources: readonly PinnedUsageSource[];
|
||||
errorLabel: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const [available, setAvailable] = useState<number | null>(null);
|
||||
@@ -144,7 +164,7 @@ function PinnedUsageTrigger({
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={pinnedUsageLabel(label, sources)}
|
||||
accessibilityLabel={pinnedUsageLabel(label, sources, errorLabel)}
|
||||
style={triggerStyle}
|
||||
testID="sidebar-usage"
|
||||
>
|
||||
@@ -185,10 +205,17 @@ function PinnedUsageLine({
|
||||
return sources.map((source, sourceIndex) => (
|
||||
<Fragment key={source.key}>
|
||||
<View
|
||||
style={sourceIndex === 0 ? null : styles.sourceGap}
|
||||
style={[styles.source, sourceIndex === 0 ? null : styles.sourceGap]}
|
||||
testID={measuring ? undefined : "sidebar-usage-source"}
|
||||
>
|
||||
<UsageSourceIcon svg={source.icon} size={14} />
|
||||
{source.hasError ? (
|
||||
<ThemedTriangleAlert
|
||||
size={12}
|
||||
uniProps={errorIconColor}
|
||||
testID={measuring ? undefined : "sidebar-usage-error"}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{source.windows.map((window, windowIndex) => {
|
||||
const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap;
|
||||
@@ -251,6 +278,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
source: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sourceGap: {
|
||||
marginLeft: theme.spacing[3],
|
||||
},
|
||||
|
||||
@@ -184,6 +184,55 @@ describe("planWorkspaceOpenTargets", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens the branch and the active file on the web host the daemon resolved for an SSH alias", () => {
|
||||
const aliasStatus = {
|
||||
isGit: true,
|
||||
remoteUrl: "git@github.com-work:acme/repo.git",
|
||||
currentBranch: "feature/x",
|
||||
forgeWeb: { host: "github.com" },
|
||||
};
|
||||
const open = (activeFile?: { path: string }) =>
|
||||
planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/repo",
|
||||
activeFile,
|
||||
desktopTargets: [],
|
||||
canUseDesktopBridge: false,
|
||||
isLocalExecution: false,
|
||||
checkoutStatus: aliasStatus,
|
||||
forge: "github",
|
||||
}).map((target) => (target.source === "forge" ? target.url : null));
|
||||
|
||||
expect(open()).toEqual(["https://github.com/acme/repo/tree/feature/x"]);
|
||||
expect(open({ path: "src/app.ts" })).toEqual([
|
||||
"https://github.com/acme/repo/blob/feature/x/src/app.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the branch and file on the origin repository of a fork", () => {
|
||||
const forkStatus = {
|
||||
isGit: true,
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
currentBranch: "feature/only-on-fork",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
};
|
||||
const forgeUrls = (activeFile: { path: string } | undefined) =>
|
||||
planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/repo",
|
||||
activeFile,
|
||||
desktopTargets: [],
|
||||
canUseDesktopBridge: false,
|
||||
isLocalExecution: false,
|
||||
checkoutStatus: forkStatus,
|
||||
forge: "github",
|
||||
}).flatMap((target) => (target.source === "forge" ? [target.url] : []));
|
||||
const urls = [...forgeUrls(undefined), ...forgeUrls({ path: "src/app.ts" })];
|
||||
|
||||
expect(urls).toEqual([
|
||||
"https://github.com/fork-owner/repo/tree/feature/only-on-fork",
|
||||
"https://github.com/fork-owner/repo/blob/feature/only-on-fork/src/app.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("infers the forge from the remote URL when the forge input is null", () => {
|
||||
const targets = planWorkspaceOpenTargets({
|
||||
workspaceDirectory: "/repo",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import { type Forge, forgeFromRemoteUrl, getForgePresentation } from "@/git/forge";
|
||||
import type { DesktopOpenTarget, OpenDesktopTargetInput } from "@/workspace/desktop-open-targets";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
interface CheckoutStatusForOpenTarget {
|
||||
isGit: boolean;
|
||||
remoteUrl?: string | null;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
currentBranch?: string | null;
|
||||
}
|
||||
|
||||
@@ -118,6 +120,7 @@ function buildForgeWebUrl(
|
||||
forge: Forge,
|
||||
input: {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb: ForgeWeb | null | undefined;
|
||||
branch: string | null | undefined;
|
||||
path: string | null;
|
||||
lineStart?: number;
|
||||
@@ -129,6 +132,7 @@ function buildForgeWebUrl(
|
||||
return (
|
||||
presentation.buildBlobUrl?.({
|
||||
remoteUrl: input.remoteUrl,
|
||||
forgeWeb: input.forgeWeb,
|
||||
branch: input.branch,
|
||||
path: input.path,
|
||||
lineStart: input.lineStart,
|
||||
@@ -139,6 +143,7 @@ function buildForgeWebUrl(
|
||||
return (
|
||||
presentation.buildBranchTreeUrl?.({
|
||||
remoteUrl: input.remoteUrl,
|
||||
forgeWeb: input.forgeWeb,
|
||||
branch: input.branch,
|
||||
}) ?? null
|
||||
);
|
||||
@@ -159,6 +164,7 @@ function planForgeOpenTarget(input: {
|
||||
}
|
||||
const url = buildForgeWebUrl(forge, {
|
||||
remoteUrl: input.checkoutStatus.remoteUrl,
|
||||
forgeWeb: input.checkoutStatus.forgeWeb,
|
||||
branch: input.checkoutStatus.currentBranch,
|
||||
path: input.resolvedFile?.relativePath ?? null,
|
||||
lineStart: input.activeFile?.lineStart,
|
||||
|
||||
@@ -306,6 +306,11 @@ export interface PaseoAgentTimelineRefetchOptions {
|
||||
cursor?: FetchAgentTimelineCursor;
|
||||
limit?: number;
|
||||
projection?: FetchAgentTimelineProjection;
|
||||
/**
|
||||
* Extends a `tail` or `before` page back to the start of the oldest turn it touches, so
|
||||
* `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn.
|
||||
*/
|
||||
wholeTurns?: boolean;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -393,6 +393,7 @@ describe("lifecycle hook SDK types", () => {
|
||||
expect(
|
||||
typeErrors(`
|
||||
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.addHook("worktree.create.before", async ({ request }) => {
|
||||
if (request.kind === "checkout-branch") {
|
||||
@@ -426,8 +427,17 @@ describe("lifecycle hook SDK types", () => {
|
||||
const { metadata, ...withoutMetadata } = request;
|
||||
return withoutMetadata;
|
||||
});
|
||||
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
|
||||
server.addHook("agent.turn.after", async (event, { paseo, signal }) => {
|
||||
const { agent, outcome } = event;
|
||||
const kind: "completed" | "failed" | "canceled" = outcome.kind;
|
||||
// @ts-expect-error Turn payloads carry no history; read it from the agent's timeline.
|
||||
event.timeline;
|
||||
const turn = await paseo.agents
|
||||
.ref(agent.id)
|
||||
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const items: readonly AgentTimelineItem[] = turn.entries
|
||||
.filter((entry) => entry.turnId === event.turnId)
|
||||
.map(({ item }) => item);
|
||||
// @ts-expect-error Notification payloads have no creation request.
|
||||
outcome.request;
|
||||
});
|
||||
|
||||
@@ -121,6 +121,22 @@ export interface PluginSidebarViewContribution<Text extends PluginText = string>
|
||||
Component: ComponentType<PluginSidebarViewProps>;
|
||||
}
|
||||
|
||||
export interface PluginWorkspaceRowBadgeItemProps extends PluginHostProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of the line under a workspace's title in the sidebar, on every workspace of the plugin's host.
|
||||
* Render `WorkspaceRowBadge`, or nothing when there is nothing to say about this workspace.
|
||||
*/
|
||||
export interface PluginWorkspaceRowBadgeItemContribution<Text extends PluginText = string> {
|
||||
id: string;
|
||||
/** The item's toggle in the sidebar's Show menu. */
|
||||
title: Text;
|
||||
icon: PluginIcon;
|
||||
Component: ComponentType<PluginWorkspaceRowBadgeItemProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
|
||||
* and color; buttons widen the props with their host and target context.
|
||||
@@ -214,6 +230,9 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
|
||||
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
||||
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
||||
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
|
||||
addWorkspaceRowBadgeItem(
|
||||
contribution: PluginWorkspaceRowBadgeItemContribution<PluginText>,
|
||||
): PluginCleanup;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution<PluginText>): PluginCleanup;
|
||||
addCommandCenterItem(
|
||||
contribution: PluginCommandCenterItemContribution<PluginText>,
|
||||
|
||||
@@ -18,6 +18,8 @@ export type {
|
||||
PluginSidebarViewProps,
|
||||
PluginSidebarViewContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginWorkspaceRowBadgeItemProps,
|
||||
PluginWorkspaceRowBadgeItemContribution,
|
||||
PluginIcon,
|
||||
PluginPanelLocation,
|
||||
PluginOpenPanelOptions,
|
||||
|
||||
@@ -173,6 +173,19 @@ export declare const SidebarRow: ComponentType<SidebarRowProps>;
|
||||
/** A line between groups of rows. Render it from a sidebar item's `Component`. */
|
||||
export declare const SidebarSeparator: ComponentType;
|
||||
|
||||
export type WorkspaceRowBadgeTone = "muted" | "success" | "warning" | "danger" | "info";
|
||||
export interface WorkspaceRowBadgeProps {
|
||||
/** A Lucide icon name, inline SVG, or a component. */
|
||||
icon?: SidebarIcon;
|
||||
label?: string;
|
||||
/** Default "muted". */
|
||||
tone?: WorkspaceRowBadgeTone;
|
||||
/** Defaults to `label`, then the item's `title`. */
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
/** One item on a sidebar workspace row's meta line. Render it from a workspace row badge item's `Component`. */
|
||||
export declare const WorkspaceRowBadge: ComponentType<WorkspaceRowBadgeProps>;
|
||||
|
||||
/** Paseo's loading state. Fills the panel; the panel decides when to show it. */
|
||||
export declare const Loading: ComponentType;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { AgentToolDefinition } from "./agent-tools.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentTimelineItem,
|
||||
AgentSessionConfig,
|
||||
JsonValue,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
@@ -104,12 +103,13 @@ export interface PluginHookEvents {
|
||||
"agent.turn.after": {
|
||||
agent: PluginHookAgent;
|
||||
turnId: string | null;
|
||||
outcome: PluginTurnOutcome;
|
||||
/**
|
||||
* @deprecated Always empty. Fetch history on demand with
|
||||
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
|
||||
* The payload carries no history. Read the turn with
|
||||
* `paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true })`
|
||||
* and keep the entries whose `turnId` equals this `turnId`. The result is empty when a newer turn
|
||||
* started before the read, or when `turnId` is `null`.
|
||||
*/
|
||||
timeline: readonly AgentTimelineItem[];
|
||||
outcome: PluginTurnOutcome;
|
||||
};
|
||||
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
|
||||
"agent.permission.after": {
|
||||
|
||||
@@ -108,6 +108,26 @@ export function normalizeHost(host: string): string {
|
||||
return host.trim().replace(/\.+$/u, "").toLowerCase();
|
||||
}
|
||||
|
||||
/** Whether `host` is one of the forge's public cloud hosts, SSH aliases included. */
|
||||
export function isForgeCloudHost(forge: string, host: string): boolean {
|
||||
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
|
||||
return cloudHosts.includes(normalizeHost(host));
|
||||
}
|
||||
|
||||
/**
|
||||
* Web host for a remote host of `forge`. A cloud SSH alias (a non-first entry
|
||||
* in the forge's `cloudHosts`, e.g. ssh.github.com) maps to the forge's web
|
||||
* host (`cloudHosts[0]`); any other host, self-hosted included, is returned
|
||||
* as is.
|
||||
*/
|
||||
export function forgeWebHost(forge: string, host: string): string {
|
||||
const normalized = normalizeHost(host);
|
||||
if (!isForgeCloudHost(forge, normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return normalizeHost(getForgeDefinition(forge)?.cloudHosts?.[0] ?? normalized);
|
||||
}
|
||||
|
||||
function normalizeRemotePath(path: string): string | null {
|
||||
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
|
||||
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
|
||||
|
||||
@@ -4328,6 +4328,22 @@ export const WorkspaceScriptPayloadSchema = z.object({
|
||||
terminalId: z.string().nullable().optional().default(null),
|
||||
});
|
||||
|
||||
/**
|
||||
* Where a workspace's forge is on the web, resolved by the daemon so every
|
||||
* client builds the same links.
|
||||
*/
|
||||
export const ForgeWebSchema = z.object({
|
||||
/** Web host of the forge: the SSH config alias resolved, cloud SSH hosts mapped to their web host. */
|
||||
host: z.string(),
|
||||
/**
|
||||
* Repository path that owns issue and change-request numbers when it is not
|
||||
* the origin repository, such as a fork's parent. Absent: the origin repository.
|
||||
*/
|
||||
referenceRepository: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ForgeWeb = z.infer<typeof ForgeWebSchema>;
|
||||
|
||||
const WorkspaceGitRuntimePayloadSchema = z
|
||||
.object({
|
||||
currentBranch: z.string().nullable().optional(),
|
||||
@@ -4464,6 +4480,8 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
// until 2027-01-17; remove the consumer fallback once the supported daemon
|
||||
// floor is >= v0.2.0.
|
||||
forge: z.string().optional(),
|
||||
// Absent from daemons older than v0.16.0; clients then derive links from the origin remote.
|
||||
forgeWeb: ForgeWebSchema.optional(),
|
||||
project: ProjectPlacementPayloadSchema.optional(),
|
||||
// COMPAT(directorySync): sequence of this latest directory projection.
|
||||
syncSeq: z.number().int().positive().optional(),
|
||||
@@ -5697,6 +5715,7 @@ const CheckoutStatusCommonSchema = z.object({
|
||||
// upstream branch name is not necessarily currentBranch, so composing one is wrong.
|
||||
// aheadOfOrigin/behindOfOrigin are measured against exactly this ref.
|
||||
upstreamRef: z.string().nullable().optional(),
|
||||
forgeWeb: ForgeWebSchema.optional(),
|
||||
});
|
||||
|
||||
const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({
|
||||
|
||||
@@ -1251,6 +1251,15 @@ describe("workspace message schemas", () => {
|
||||
expect(
|
||||
WorkspaceDescriptorPayloadSchema.parse({ ...baseWorkspace, forge: "gitlab" }).forge,
|
||||
).toBe("gitlab");
|
||||
|
||||
// forgeWeb is optional: an old daemon omits it, a new daemon's value round-trips.
|
||||
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).forgeWeb).toBeUndefined();
|
||||
expect(
|
||||
WorkspaceDescriptorPayloadSchema.parse({
|
||||
...baseWorkspace,
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
}).forgeWeb,
|
||||
).toEqual({ host: "github.com", referenceRepository: "main-owner/repo" });
|
||||
});
|
||||
|
||||
test("workspace.create.request rejects old flat backing shape and accepts new source envelope", () => {
|
||||
|
||||
@@ -13334,7 +13334,6 @@ test.each([
|
||||
},
|
||||
turnId: "hook-turn",
|
||||
outcome,
|
||||
timeline: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import { omitNullArgs } from "../tool-call-args.js";
|
||||
|
||||
interface BashToolInput {
|
||||
command: string;
|
||||
@@ -324,7 +325,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToo
|
||||
}
|
||||
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
|
||||
if (schema) {
|
||||
const parsed = schema.safeParse(rawArgs);
|
||||
const parsed = schema.safeParse(omitNullArgs(rawArgs));
|
||||
if (parsed.success) {
|
||||
return {
|
||||
kind: toolName as SimpleToolKind,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import type { ProviderImageOutput } from "../provider-image-output.js";
|
||||
import { omitNullArgs } from "../tool-call-args.js";
|
||||
|
||||
interface BashToolInput {
|
||||
command: string;
|
||||
@@ -331,7 +332,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedTool
|
||||
}
|
||||
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
|
||||
if (schema) {
|
||||
const parsed = schema.safeParse(rawArgs);
|
||||
const parsed = schema.safeParse(omitNullArgs(rawArgs));
|
||||
if (parsed.success) {
|
||||
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseToolArgs as parseOmpToolArgs } from "./omp/tool-call-detail.js";
|
||||
import { parseToolArgs as parsePiToolArgs } from "./pi/tool-call-mapper.js";
|
||||
|
||||
// Models send `null` for optional arguments (captured in #7011: read offset/limit), which the
|
||||
// runtimes treat as omitted. Rejecting them turned every such call into an unknown card.
|
||||
describe.each([
|
||||
["Pi", parsePiToolArgs],
|
||||
["OMP", parseOmpToolArgs],
|
||||
])("%s tool args with null optional values", (_provider, parseToolArgs) => {
|
||||
test.each([
|
||||
["read", { path: "./preview.png", offset: null, limit: null }, { path: "./preview.png" }],
|
||||
["bash", { command: "ls", timeout: null }, { command: "ls" }],
|
||||
])("parses %s as its own tool kind", (toolName, rawArgs, args) => {
|
||||
expect(parseToolArgs(toolName, rawArgs)).toEqual({ kind: toolName, toolName, args });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// Models routinely send `null` for optional tool arguments (e.g. read offset/limit); the
|
||||
// runtimes treat that as omitted, so the mappers must too or the call degrades to an
|
||||
// unknown card that carries the raw result.
|
||||
export function omitNullArgs(rawArgs: unknown): unknown {
|
||||
if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) {
|
||||
return rawArgs;
|
||||
}
|
||||
return Object.fromEntries(Object.entries(rawArgs).filter(([, value]) => value !== null));
|
||||
}
|
||||
@@ -1,13 +1,52 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
|
||||
import { CheckoutPrStatusSchema, CheckoutStatusResponseSchema } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
||||
import {
|
||||
buildCheckoutPrStatusPayloadFromSnapshot,
|
||||
buildCheckoutStatusPayloadFromSnapshot,
|
||||
normalizeCheckoutPrStatusPayload,
|
||||
} from "./status-projection.js";
|
||||
|
||||
describe("checkout status projection", () => {
|
||||
test("publishes where the forge is on the web with the checkout status", () => {
|
||||
const snapshot: WorkspaceGitRuntimeSnapshot = {
|
||||
cwd: "/repo",
|
||||
git: {
|
||||
isGit: true,
|
||||
repoRoot: "/repo",
|
||||
mainRepoRoot: null,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "git@github.com-work:fork-owner/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
baseRef: "main",
|
||||
aheadBehind: null,
|
||||
upstreamRef: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: true,
|
||||
diffStat: null,
|
||||
},
|
||||
forge: { featuresEnabled: true, authState: "authenticated", pullRequest: null, error: null },
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
};
|
||||
|
||||
const payload = buildCheckoutStatusPayloadFromSnapshot({
|
||||
cwd: "/repo",
|
||||
requestId: "req-1",
|
||||
snapshot,
|
||||
});
|
||||
|
||||
expect(payload.forgeWeb).toEqual({
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
expect(
|
||||
CheckoutStatusResponseSchema.parse({ type: "checkout_status_response", payload }).payload,
|
||||
).toMatchObject({ forgeWeb: payload.forgeWeb });
|
||||
});
|
||||
|
||||
test("includes repository identity fields on the PR status wire payload", () => {
|
||||
const payload = normalizeCheckoutPrStatusPayload(
|
||||
{
|
||||
|
||||
@@ -69,6 +69,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
|
||||
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||
hasRemote: snapshot.git.hasRemote,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
...(snapshot.forgeWeb ? { forgeWeb: snapshot.forgeWeb } : {}),
|
||||
isPaseoOwnedWorktree: true,
|
||||
error: null,
|
||||
requestId,
|
||||
@@ -91,6 +92,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({
|
||||
behindOfOrigin: snapshot.git.behindOfOrigin ?? null,
|
||||
hasRemote: snapshot.git.hasRemote,
|
||||
remoteUrl: snapshot.git.remoteUrl,
|
||||
...(snapshot.forgeWeb ? { forgeWeb: snapshot.forgeWeb } : {}),
|
||||
isPaseoOwnedWorktree: false,
|
||||
error: null,
|
||||
requestId,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type CheckoutDiffCompare,
|
||||
} from "../../utils/checkout-git.js";
|
||||
import { lstat, readFile, readlink } from "node:fs/promises";
|
||||
import type { BigIntStats } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { DiffEndpoint, DiffSubscribeRequest } from "@getpaseo/protocol/messages";
|
||||
import type { DiffEntry, DiffBody } from "@getpaseo/protocol/binary-frames/index";
|
||||
@@ -27,6 +26,16 @@ export interface ListedFile {
|
||||
identity: string;
|
||||
source: { kind: "tracked" } | { kind: "untracked"; size: number };
|
||||
}
|
||||
interface WorktreeInput {
|
||||
/** Comparison-side mode, or null when the file is added/untracked on this side. */
|
||||
oldMode: string | null;
|
||||
/** Comparison-side blob, or null when the file is added/untracked on this side. */
|
||||
oldBlob: string | null;
|
||||
/** A regular file whose bytes readGitInputs blob-hashes. */
|
||||
hashed: boolean;
|
||||
/** Content Git cannot read as a regular file, such as a symlink target or submodule state. */
|
||||
literal: string | null;
|
||||
}
|
||||
interface ResolvedComparison {
|
||||
baseSha: string;
|
||||
headSha: string;
|
||||
@@ -195,20 +204,47 @@ async function readComparisonList(
|
||||
...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}),
|
||||
},
|
||||
};
|
||||
const { files, worktreeFiles, interpretationPaths } = await readTrackedFiles(
|
||||
compare,
|
||||
args,
|
||||
options,
|
||||
);
|
||||
if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options)));
|
||||
const tracked = await readTrackedFiles(compare, args, options);
|
||||
const files = tracked.files;
|
||||
const worktreeInputs = tracked.worktreeInputs;
|
||||
if (compare.head.kind === "worktree") {
|
||||
const untracked = await listUntrackedFiles(options);
|
||||
files.push(...untracked.files);
|
||||
for (const [path, input] of untracked.worktreeInputs) worktreeInputs.set(path, input);
|
||||
}
|
||||
files.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
|
||||
for (const file of files) interpretationPaths.add(file.entry.path);
|
||||
const gitInputs = await readGitInputs([...interpretationPaths].sort(), worktreeFiles, options);
|
||||
for (const file of files)
|
||||
for (const file of files) tracked.interpretationPaths.add(file.entry.path);
|
||||
const hashedPaths = new Set(
|
||||
[...worktreeInputs].filter(([, input]) => input.hashed).map(([path]) => path),
|
||||
);
|
||||
const literalContent = new Map(
|
||||
[...worktreeInputs].flatMap(([path, input]) =>
|
||||
input.literal === null ? [] : [[path, input.literal] as const],
|
||||
),
|
||||
);
|
||||
const gitInputs = await readGitInputs({
|
||||
paths: [...tracked.interpretationPaths].sort(),
|
||||
hashedPaths,
|
||||
options,
|
||||
literalContent,
|
||||
});
|
||||
for (const file of files) {
|
||||
if (compare.head.kind === "worktree") {
|
||||
const input = worktreeInputs.get(file.entry.path);
|
||||
if (!input) throw new Error(`Missing worktree identity for ${file.entry.path}`);
|
||||
file.identity = JSON.stringify([
|
||||
input.oldMode,
|
||||
input.oldBlob,
|
||||
gitInputs.identities.get(file.entry.path) ?? null,
|
||||
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path) ?? null,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
file.identity += JSON.stringify([
|
||||
gitInputs.identities.get(file.entry.path),
|
||||
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path),
|
||||
]);
|
||||
}
|
||||
const metrics = { highlightCalls: 0 };
|
||||
const patches = new Patches(args, options, files);
|
||||
const sources = new HighlightSources({
|
||||
@@ -237,7 +273,15 @@ async function readComparisonList(
|
||||
const result =
|
||||
file.source.kind === "untracked"
|
||||
? await runGitCommand(
|
||||
["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path],
|
||||
[
|
||||
"diff",
|
||||
"--no-index",
|
||||
"--no-textconv",
|
||||
...GIT_PATCH_FORMAT_ARGS,
|
||||
"--",
|
||||
"/dev/null",
|
||||
file.entry.path,
|
||||
],
|
||||
{
|
||||
...options,
|
||||
acceptExitCodes: [0, 1],
|
||||
@@ -293,7 +337,11 @@ async function readTrackedFiles(
|
||||
compare: Compare,
|
||||
args: string[],
|
||||
options: GitCommandOptions,
|
||||
): Promise<{ files: ListedFile[]; worktreeFiles: Set<string>; interpretationPaths: Set<string> }> {
|
||||
): Promise<{
|
||||
files: ListedFile[];
|
||||
worktreeInputs: Map<string, WorktreeInput>;
|
||||
interpretationPaths: Set<string>;
|
||||
}> {
|
||||
const [raw, stat] = await Promise.all([
|
||||
runGitCommand([...args.filter((arg) => arg !== "-w"), "--raw", "--no-abbrev", "-z"], options),
|
||||
runGitCommand([...args, "--numstat", "-z"], options),
|
||||
@@ -303,7 +351,7 @@ async function readTrackedFiles(
|
||||
const stats = parseStats(stat.stdout);
|
||||
const fields = raw.stdout.split("\0");
|
||||
const files: ListedFile[] = [];
|
||||
const worktreeFiles = new Set<string>();
|
||||
const worktreeInputs = new Map<string, WorktreeInput>();
|
||||
const interpretationPaths = new Set<string>();
|
||||
for (let index = 0; index < fields.length - 1; index++) {
|
||||
const header = fields[index].split(" ");
|
||||
@@ -325,10 +373,25 @@ async function readTrackedFiles(
|
||||
else if (code === "D") change = "deleted";
|
||||
else if (oldPath !== null) change = "renamed";
|
||||
let identity = header.join(" ");
|
||||
if (compare.head.kind === "worktree" && code !== "D") {
|
||||
const worktree = await readWorktreeIdentity(path, header, options);
|
||||
identity += `:${worktree.identity}`;
|
||||
if (worktree.regular && !counts.binary) worktreeFiles.add(path);
|
||||
if (compare.head.kind === "worktree") {
|
||||
const input: WorktreeInput = {
|
||||
oldMode: code === "A" ? null : header[0],
|
||||
oldBlob: code === "A" ? null : absentBlob(header[2]),
|
||||
hashed: false,
|
||||
literal: null,
|
||||
};
|
||||
if (code !== "D") {
|
||||
const worktree = await readWorktreeContent({
|
||||
path,
|
||||
header,
|
||||
text: !counts.binary,
|
||||
options,
|
||||
});
|
||||
input.hashed = worktree.hashed;
|
||||
input.literal = worktree.literal;
|
||||
}
|
||||
worktreeInputs.set(path, input);
|
||||
identity = "";
|
||||
}
|
||||
|
||||
files.push({
|
||||
@@ -345,24 +408,32 @@ async function readTrackedFiles(
|
||||
source: { kind: "tracked" },
|
||||
});
|
||||
}
|
||||
return { files, worktreeFiles, interpretationPaths };
|
||||
return { files, worktreeInputs, interpretationPaths };
|
||||
}
|
||||
|
||||
async function readWorktreeIdentity(
|
||||
path: string,
|
||||
header: string[],
|
||||
options: GitCommandOptions,
|
||||
): Promise<{ identity: string; regular: boolean }> {
|
||||
const value = await lstat(resolve(options.cwd, path), { bigint: true });
|
||||
let identity = fileStatIdentity(value);
|
||||
async function readWorktreeContent({
|
||||
path,
|
||||
header,
|
||||
text,
|
||||
options,
|
||||
}: {
|
||||
path: string;
|
||||
header: string[];
|
||||
text: boolean;
|
||||
options: GitCommandOptions;
|
||||
}): Promise<{ hashed: boolean; literal: string | null }> {
|
||||
const absolutePath = resolve(options.cwd, path);
|
||||
const value = await lstat(absolutePath, { bigint: true });
|
||||
if (value.isSymbolicLink())
|
||||
return { hashed: false, literal: `symlink:${await readlink(absolutePath)}` };
|
||||
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
|
||||
const submodule = await runGitCommand(
|
||||
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
|
||||
{ cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
|
||||
{ cwd: absolutePath, envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
|
||||
);
|
||||
identity += `:${submodule.stdout}`;
|
||||
return { hashed: false, literal: `submodule:${submodule.stdout}` };
|
||||
}
|
||||
return { identity, regular: value.isFile() };
|
||||
return { hashed: value.isFile() && text, literal: null };
|
||||
}
|
||||
|
||||
function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] {
|
||||
@@ -385,9 +456,12 @@ async function readUntrackedText(
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFile[]> {
|
||||
async function listUntrackedFiles(
|
||||
options: GitCommandOptions,
|
||||
): Promise<{ files: ListedFile[]; worktreeInputs: Map<string, WorktreeInput> }> {
|
||||
const cwd = options.cwd;
|
||||
const files: ListedFile[] = [];
|
||||
const worktreeInputs = new Map<string, WorktreeInput>();
|
||||
const untracked = await runGitCommand(
|
||||
["ls-files", "--others", "--exclude-standard", "-z"],
|
||||
options,
|
||||
@@ -408,6 +482,12 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
|
||||
binary || content.length === 0
|
||||
? 0
|
||||
: content.toString("utf8").split("\n").length - Number(content.at(-1) === 10);
|
||||
worktreeInputs.set(path, {
|
||||
oldMode: null,
|
||||
oldBlob: null,
|
||||
hashed: !symlink && !binary && !omitted,
|
||||
literal: symlink ? `symlink:${content.toString("utf8")}` : null,
|
||||
});
|
||||
files.push({
|
||||
entry: {
|
||||
path,
|
||||
@@ -418,14 +498,13 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
|
||||
deletions: 0,
|
||||
body: !binary,
|
||||
},
|
||||
identity: fileStatIdentity(value),
|
||||
identity: "",
|
||||
source: { kind: "untracked", size: Number(value.size) },
|
||||
});
|
||||
}
|
||||
return files;
|
||||
return { files, worktreeInputs };
|
||||
}
|
||||
|
||||
function fileStatIdentity(value: BigIntStats): string {
|
||||
// Timestamp-preserving copies still change file status or replace the inode.
|
||||
return `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`;
|
||||
function absentBlob(value: string): string | null {
|
||||
return /^0+$/.test(value) ? null : value;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,20 @@ interface Interpretation {
|
||||
|
||||
// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git
|
||||
// for effective attributes/config and filtered blob identities without writing objects.
|
||||
export async function readGitInputs(
|
||||
paths: string[],
|
||||
worktreeFiles: Set<string>,
|
||||
options: GitCommandOptions,
|
||||
) {
|
||||
const regular = paths.filter((path) => worktreeFiles.has(path));
|
||||
interface ReadGitInputsOptions {
|
||||
paths: string[];
|
||||
hashedPaths: ReadonlySet<string>;
|
||||
options: GitCommandOptions;
|
||||
literalContent?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export async function readGitInputs({
|
||||
paths,
|
||||
hashedPaths,
|
||||
options,
|
||||
literalContent = new Map(),
|
||||
}: ReadGitInputsOptions) {
|
||||
const regular = paths.filter((path) => hashedPaths.has(path));
|
||||
const [interpretation, blobs] = await Promise.all([
|
||||
readInterpretation(paths, options),
|
||||
regular.length
|
||||
@@ -34,7 +42,7 @@ export async function readGitInputs(
|
||||
fingerprint([
|
||||
interpretation.config,
|
||||
interpretation.attributes.get(path) ?? [],
|
||||
blobByPath.get(path) ?? null,
|
||||
blobByPath.get(path) ?? literalContent.get(path) ?? null,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -530,6 +530,222 @@ test("submodule commits invalidate cached worktree gitlink bodies", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-only changes reuse cached bodies and send no body frames", async () => {
|
||||
const { utimes } = await import("node:fs/promises");
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-stat-only-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
const path = join(cwd, "file.txt");
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(join(cwd, "README"), "base\n");
|
||||
await writeFile(path, "base\n");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
|
||||
await writeFile(path, "changed\n");
|
||||
const subscription = await streams.subscribe(
|
||||
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
|
||||
async (frame) => {
|
||||
frames.push(frame);
|
||||
},
|
||||
);
|
||||
await subscription.start();
|
||||
expect(
|
||||
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
|
||||
).toContainEqual(expect.objectContaining({ path: "file.txt" }));
|
||||
frames.length = 0;
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const ended = frames.filter((frame) => frame.opcode === DiffOpcode.End).length;
|
||||
const timestamp = new Date(Date.now() + 1000 * (index + 1));
|
||||
await utimes(path, timestamp, timestamp);
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.filter((frame) => frame.opcode === DiffOpcode.End).length, {
|
||||
timeout: 5000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(ended + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
|
||||
const beginnings = frames.filter((frame) => frame.opcode === DiffOpcode.Begin);
|
||||
expect(beginnings.length).toBeGreaterThanOrEqual(3);
|
||||
for (const begin of beginnings) {
|
||||
expect(begin.entries.find((entry) => entry.path === "file.txt")?.body).toBe(false);
|
||||
}
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("staging unchanged worktree content reuses its cached body", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-staging-only-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(join(cwd, "README"), "base\n");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
|
||||
await writeFile(join(cwd, "staged.ts"), "export const staged = true;\n");
|
||||
const subscription = await streams.subscribe(
|
||||
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
|
||||
async (frame) => {
|
||||
frames.push(frame);
|
||||
},
|
||||
);
|
||||
await subscription.start();
|
||||
expect(
|
||||
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
|
||||
).toContainEqual(expect.objectContaining({ path: "staged.ts" }));
|
||||
frames.length = 0;
|
||||
git("add", "-A");
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
|
||||
.toBe(true);
|
||||
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
|
||||
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
|
||||
entries: [{ path: "staged.ts", change: "added", body: false }],
|
||||
});
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"untracked bodies use the same patch conversion as tracked files",
|
||||
async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-textconv-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(join(cwd, ".gitattributes"), "*.dat diff=upper\n");
|
||||
git("config", "diff.upper.textconv", "sed s/hello/HELLO/");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
|
||||
await writeFile(join(cwd, "x.dat"), "hello\n");
|
||||
const subscription = await streams.subscribe(
|
||||
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
|
||||
async (frame) => {
|
||||
frames.push(frame);
|
||||
},
|
||||
);
|
||||
await subscription.start();
|
||||
const initial = frames
|
||||
.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : []))
|
||||
.find((file) => file.path === "x.dat");
|
||||
expect(initial?.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
|
||||
type: "add",
|
||||
content: "hello",
|
||||
});
|
||||
frames.length = 0;
|
||||
git("add", "x.dat");
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
|
||||
.toBe(true);
|
||||
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"comparison-side file type changes refresh cached bodies",
|
||||
async () => {
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-base-mode-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
const path = join(cwd, "x");
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(path, "target");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "regular");
|
||||
git("branch", "compare");
|
||||
await rm(path);
|
||||
await symlink("target", path);
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "symlink");
|
||||
const moved = git("rev-parse", "HEAD");
|
||||
await rm(path);
|
||||
await writeFile(path, "target\n");
|
||||
const compare = {
|
||||
cwd,
|
||||
base: { kind: "rev" as const, rev: "refs/heads/compare" },
|
||||
head: { kind: "worktree" as const },
|
||||
};
|
||||
const subscription = await streams.subscribe(compare, async (frame) => {
|
||||
frames.push(frame);
|
||||
});
|
||||
await subscription.start();
|
||||
expect(
|
||||
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
|
||||
).toContainEqual(expect.objectContaining({ path: "x" }));
|
||||
frames.length = 0;
|
||||
git("update-ref", "refs/heads/compare", moved);
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
|
||||
.toBe(true);
|
||||
const bodies = frames.flatMap((frame) =>
|
||||
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
|
||||
);
|
||||
expect(bodies).toContainEqual(expect.objectContaining({ path: "x" }));
|
||||
const { listComparison } = await import("./git.js");
|
||||
const fresh = await listComparison(compare);
|
||||
try {
|
||||
const record = await fresh.read(fresh.files[0]);
|
||||
if (record.kind !== "ready") throw new Error("Expected readable type change");
|
||||
expect(bodies).toContainEqual(record.body);
|
||||
} finally {
|
||||
await fresh.close();
|
||||
}
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([true, false])(
|
||||
"tracked=%s timestamp-preserving replacements refresh cached bodies",
|
||||
async (tracked) => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { forgeWebHost } from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeResolution } from "../services/forge-resolver.js";
|
||||
|
||||
// A failed reference-repository lookup (signed out, network down, rate limit) is retried after this long.
|
||||
const REFERENCE_REPOSITORY_RETRY_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Web location of a workspace's forge, remembered per origin remote. The host
|
||||
* comes from the forge resolution; the reference repository needs the forge, so
|
||||
* it is looked up once per remote, and again only after a failed lookup, once
|
||||
* the retry delay has passed.
|
||||
*/
|
||||
export interface ForgeWebEntry {
|
||||
remoteUrl: string;
|
||||
remoteKey: string;
|
||||
forgeWeb: ForgeWeb;
|
||||
retryAtMs: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The web location available without the forge lookup: a settled answer for this
|
||||
* remote, or the host alone while its reference repository is due. Publish this
|
||||
* with the snapshot immediately; a slow or signed-out forge CLI must not hold
|
||||
* back the workspace's git state or pull request status.
|
||||
*/
|
||||
export function forgeWebForResolution(input: {
|
||||
remoteUrl: string;
|
||||
resolution: ForgeResolution;
|
||||
previous: ForgeWebEntry | null;
|
||||
nowMs: number;
|
||||
}): ForgeWebEntry {
|
||||
const remoteKey = `${input.resolution.forge}\0${input.remoteUrl}`;
|
||||
const previous = input.previous?.remoteKey === remoteKey ? input.previous : null;
|
||||
// A settled lookup stands for as long as the remote does, and a failed one keeps its retry
|
||||
// time, however often forced refreshes (every push, fetch and branch switch) ask.
|
||||
if (previous) {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
remoteUrl: input.remoteUrl,
|
||||
remoteKey,
|
||||
forgeWeb: { host: forgeWebHost(input.resolution.forge, input.resolution.host) },
|
||||
// A lookup that has never run is due now; one that failed keeps its own retry time.
|
||||
retryAtMs: input.resolution.service.getReferenceRepository ? input.nowMs : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches an entry with the repository that owns issue and change-request
|
||||
* numbers. The links still work without it, so a failed lookup is retried later
|
||||
* rather than surfaced.
|
||||
*/
|
||||
export async function resolveForgeWebReference(input: {
|
||||
cwd: string;
|
||||
resolution: ForgeResolution;
|
||||
entry: ForgeWebEntry;
|
||||
nowMs: number;
|
||||
}): Promise<ForgeWebEntry> {
|
||||
const host = input.entry.forgeWeb.host;
|
||||
const lookup = input.resolution.service.getReferenceRepository;
|
||||
if (!lookup) {
|
||||
return input.entry.retryAtMs === null ? input.entry : { ...input.entry, retryAtMs: null };
|
||||
}
|
||||
try {
|
||||
const referenceRepository = await lookup.call(input.resolution.service, { cwd: input.cwd });
|
||||
return {
|
||||
...input.entry,
|
||||
forgeWeb: referenceRepository ? { host, referenceRepository } : { host },
|
||||
retryAtMs: null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...input.entry,
|
||||
forgeWeb: { host },
|
||||
retryAtMs: input.nowMs + REFERENCE_REPOSITORY_RETRY_MS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -163,8 +163,14 @@ export default function contribute(server) {
|
||||
server.addHook("agent.turn.before", async (event) => {
|
||||
console.log(JSON.stringify({ hook: "agent.turn.before", event }));
|
||||
});
|
||||
server.addHook("agent.turn.after", async (event) => {
|
||||
console.log(JSON.stringify({ hook: "agent.turn.after", event }));
|
||||
server.addHook("agent.turn.after", async (event, context) => {
|
||||
const page = await context.paseo.agents
|
||||
.ref(event.agent.id)
|
||||
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const turn = page.entries
|
||||
.filter((entry) => entry.turnId === event.turnId)
|
||||
.map(({ item }) => item.type);
|
||||
console.log(JSON.stringify({ hook: "agent.turn.after", event, turn }));
|
||||
});
|
||||
server.addHook("agent.permission.before", async (event, context) => {
|
||||
console.log(JSON.stringify({ hook: "agent.permission.before", event }));
|
||||
@@ -220,6 +226,7 @@ export default function contribute(server) {
|
||||
{
|
||||
hook: "agent.turn.after",
|
||||
event: { outcome: { kind: "completed" }, timeline: [] },
|
||||
turn: ["user_message", "tool_call"],
|
||||
},
|
||||
{ hook: "agent.turn.before", event: { agent: { id: agent.id } } },
|
||||
]);
|
||||
|
||||
@@ -157,8 +157,7 @@ test.skipIf(process.platform !== "linux")(
|
||||
"This is a hook integration test. Reply exactly: out of credits. If the next user message is Try again., reply exactly RETRY_OK instead. Do not use tools.",
|
||||
);
|
||||
await waitForTurns(client, agent.id, 2);
|
||||
const retryTurns = await endedTurns(client, agent.id);
|
||||
expect(assistantText(retryTurns[1].data.timeline)).toBe("RETRY_OK");
|
||||
expect(await latestReplyText(client, agent.id)).toBe("RETRY_OK");
|
||||
proof.followup = "RETRY_OK";
|
||||
|
||||
await client.sendMessage(
|
||||
@@ -207,8 +206,7 @@ test.skipIf(process.platform !== "linux")(
|
||||
const envPermission = await waitForPermission(client, agent.id, envCommand);
|
||||
await client.respondToPermission(agent.id, envPermission, { behavior: "allow" });
|
||||
await waitForTurns(client, agent.id, 5);
|
||||
const envTurns = await endedTurns(client, agent.id);
|
||||
expect(assistantText(envTurns[4].data.timeline)).toBe("created|opened");
|
||||
expect(await latestReplyText(client, agent.id)).toBe("created|opened");
|
||||
proof.environmentOutput = "created|opened";
|
||||
|
||||
await client.refreshAgent(agent.id);
|
||||
@@ -332,6 +330,16 @@ test.skipIf(process.platform !== "linux")(
|
||||
600_000,
|
||||
);
|
||||
|
||||
// Turn hooks carry no history; read the reply the way plugins do.
|
||||
async function latestReplyText(client: DaemonClient, agentId: string): Promise<string> {
|
||||
const page = await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
wholeTurns: true,
|
||||
});
|
||||
return assistantText(page.entries.map((entry) => entry.item));
|
||||
}
|
||||
|
||||
function assistantText(timeline: readonly AgentTimelineItem[]): string {
|
||||
let text = "";
|
||||
for (const item of timeline) {
|
||||
|
||||
@@ -195,21 +195,18 @@ export function publishAgentStream(
|
||||
lifecycle.emit("agent.turn.after", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline: [],
|
||||
outcome: { kind: "completed" },
|
||||
});
|
||||
} else if (event.type === "turn_failed") {
|
||||
lifecycle.emit("agent.turn.after", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline: [],
|
||||
outcome: { kind: "failed", error: { message: event.error, code: event.code } },
|
||||
});
|
||||
} else if (event.type === "turn_canceled") {
|
||||
lifecycle.emit("agent.turn.after", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline: [],
|
||||
outcome: { kind: "canceled", reason: event.reason },
|
||||
});
|
||||
} else if (event.type === "permission_requested") {
|
||||
|
||||
@@ -564,7 +564,7 @@ export class PluginRuntime {
|
||||
type: "hook",
|
||||
requestId: randomUUID(),
|
||||
name,
|
||||
input: event,
|
||||
input: wireEvent(name, event),
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
@@ -1482,3 +1482,11 @@ function readConnectionId(value: unknown): string | null {
|
||||
const connectionId = Reflect.get(value, "connectionId");
|
||||
return typeof connectionId === "string" ? connectionId : null;
|
||||
}
|
||||
|
||||
// COMPAT(pluginTurnTimeline): added in v0.16.0-beta.3; plugins built before the field left the SDK type read `timeline`; remove after 2027-05-31
|
||||
function wireEvent<Name extends keyof PluginHookEvents>(
|
||||
name: Name,
|
||||
event: PluginHookEvents[Name],
|
||||
): PluginHookEvents[Name] {
|
||||
return name === "agent.turn.after" ? { ...event, timeline: [] } : event;
|
||||
}
|
||||
|
||||
@@ -6184,6 +6184,7 @@ export class Session {
|
||||
// resolves to "github") so the sidebar/hover-card brand mark matches the
|
||||
// status projection without a second resolve.
|
||||
forge: snapshot.forge.forge,
|
||||
forgeWeb: snapshot.forgeWeb,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6619,6 +6619,45 @@ test("lists Git runtime for a checkout explicitly owned by a non-Git project", a
|
||||
});
|
||||
});
|
||||
|
||||
test("lists where the workspace's forge is on the web so every client builds the same links", async () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-forge-web",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-forge-web",
|
||||
projectId: project.projectId,
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
session.listAgentPayloads = async () => [];
|
||||
session.projectRegistry.list = async () => [project];
|
||||
session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [
|
||||
workspace,
|
||||
];
|
||||
session.workspaceRuntimeStore.get = () => ({
|
||||
...createWorkspaceRuntimeSnapshot(REPO_CWD),
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
});
|
||||
|
||||
const [descriptor] = (
|
||||
await session.buildWorkspaceDescriptorMap({ includeGitData: true })
|
||||
).values();
|
||||
|
||||
expect(descriptor?.forgeWeb).toEqual({
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => {
|
||||
const setupSession = () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type CheckoutSnapshotFacts,
|
||||
type CheckoutStatusGit,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { createGitHubService, GitHubCommandError } from "../services/github-service.js";
|
||||
import { createFileObserver } from "./file-observer/index.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.js";
|
||||
@@ -1019,3 +1020,556 @@ test("a branch switch made before the Git metadata watcher starts still reaches
|
||||
)
|
||||
.toBe("feature");
|
||||
}, 30_000);
|
||||
|
||||
function createCheckoutWithOrigin(origin: string): string {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-forge-web-")));
|
||||
const repo = path.join(root, "repo");
|
||||
mkdirSync(repo);
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { cwd: repo, stdio: "pipe" });
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.email", "test@example.com");
|
||||
git("config", "user.name", "Test");
|
||||
writeFileSync(path.join(repo, "README.md"), "base\n");
|
||||
git("add", ".");
|
||||
git("commit", "-m", "base");
|
||||
git("remote", "add", "origin", origin);
|
||||
cleanup.push(async () =>
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }),
|
||||
);
|
||||
return repo;
|
||||
}
|
||||
|
||||
// The GitHub adapter runs for real; only the `gh` binary it spawns is scripted.
|
||||
function createGitHubWithRepositoryView(repositoryView: object) {
|
||||
return createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view") {
|
||||
return { stdout: JSON.stringify(repositoryView), stderr: "" };
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function snapshotOf(
|
||||
repo: string,
|
||||
deps: ConstructorParameters<typeof WorkspaceGitServiceImpl>[0]["deps"],
|
||||
) {
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps,
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
return {
|
||||
service,
|
||||
snapshot: await service.getSnapshot(repo, { force: true, reason: "forge-web-test" }),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectForgeWebEventually(
|
||||
service: WorkspaceGitServiceImpl,
|
||||
repo: string,
|
||||
expected: { host: string; referenceRepository?: string },
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => (await service.getSnapshot(repo, { includeForge: false })).forgeWeb, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
test("an SSH config alias remote publishes the web host the alias resolves to", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com-work:acme/repo.git");
|
||||
|
||||
const { snapshot } = await snapshotOf(repo, {
|
||||
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
|
||||
forgeOverrides: { github: createGitHubWithRepositoryView({}) },
|
||||
});
|
||||
|
||||
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
|
||||
});
|
||||
|
||||
test("a cloud SSH host publishes the forge's web host", async () => {
|
||||
const repo = createCheckoutWithOrigin("ssh://git@ssh.github.com/acme/repo.git");
|
||||
|
||||
const { snapshot } = await snapshotOf(repo, {
|
||||
forgeOverrides: { github: createGitHubWithRepositoryView({}) },
|
||||
});
|
||||
|
||||
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
|
||||
});
|
||||
|
||||
test("a fork checkout publishes its parent as the repository that owns issue and pull request numbers", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
|
||||
const { service, snapshot } = await snapshotOf(repo, {
|
||||
forgeOverrides: {
|
||||
github: createGitHubWithRepositoryView({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// The host is available with the first snapshot; the parent follows in a background update.
|
||||
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
});
|
||||
|
||||
test("a plain remote publishes no separate reference repository", async () => {
|
||||
const repo = createCheckoutWithOrigin("https://github.com/acme/repo.git");
|
||||
|
||||
const { service, snapshot } = await snapshotOf(repo, {
|
||||
forgeOverrides: {
|
||||
github: createGitHubWithRepositoryView({ owner: { login: "acme" }, name: "repo" }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
|
||||
await expectForgeWebEventually(service, repo, { host: "github.com" });
|
||||
});
|
||||
|
||||
test("a remote on no forge publishes no web location", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@bitbucket.org:acme/repo.git");
|
||||
|
||||
const { snapshot } = await snapshotOf(repo, { resolveSshHostname: async () => null });
|
||||
|
||||
expect(snapshot.forgeWeb).toBeUndefined();
|
||||
});
|
||||
|
||||
test("changing the origin remote publishes the new remote's web location, not the previous one", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view") {
|
||||
const isFork = args[2] === "fork-owner/repo";
|
||||
return {
|
||||
stdout: JSON.stringify(
|
||||
isFork
|
||||
? {
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}
|
||||
: { owner: { login: "acme" }, name: "other" },
|
||||
),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
const before = await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
|
||||
execFileSync("git", ["remote", "set-url", "origin", "git@github.com-work:acme/other.git"], {
|
||||
cwd: repo,
|
||||
});
|
||||
// The refresh a `.git/config` change schedules does not ask for forge data.
|
||||
const after = await service.getSnapshot(repo, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "forge-web-test",
|
||||
});
|
||||
|
||||
expect(after.git.remoteUrl).toBe("git@github.com-work:acme/other.git");
|
||||
expect(after.forgeWeb).toEqual({ host: "github.com" });
|
||||
expect(before.forgeWeb).toEqual({ host: "github.com" });
|
||||
}, 30_000);
|
||||
|
||||
test("a failed parent lookup is retried by the pull request status poll, keeping the host an SSH alias resolves to", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com-work:fork-owner/repo.git");
|
||||
let nowMs = Date.parse("2026-01-01T00:00:00Z");
|
||||
let ghAnswers = false;
|
||||
let lookups = 0;
|
||||
let pollStatus: ((status: null) => void) | undefined;
|
||||
const github = createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
// The reference lookup names the origin repository; the PR status load asks for the cwd repo.
|
||||
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
|
||||
lookups += 1;
|
||||
if (!ghAnswers) {
|
||||
throw new GitHubCommandError({
|
||||
args,
|
||||
cwd: repo,
|
||||
exitCode: 1,
|
||||
stderr: "error connecting to api.github.com",
|
||||
});
|
||||
}
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
});
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
now: () => new Date(nowMs),
|
||||
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
|
||||
forgeOverrides: {
|
||||
// The adapter is real; only the poll is driven by the test instead of a timer.
|
||||
github: Object.assign(Object.create(github), {
|
||||
retainCurrentPullRequestStatusPoll: (options: { onStatus?: (status: null) => void }) => {
|
||||
pollStatus = options.onStatus;
|
||||
return { unsubscribe: () => {} };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: repo }, () => {});
|
||||
cleanup.push(async () => {
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
const failed = await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
expect(failed.forgeWeb).toEqual({ host: "github.com" });
|
||||
await expect.poll(() => pollStatus, { timeout: 10_000 }).toBeDefined();
|
||||
await expect.poll(() => lookups, { timeout: 10_000 }).toBe(1);
|
||||
// Let the failed lookup record its retry time before advancing the clock.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
ghAnswers = true;
|
||||
nowMs += 61_000;
|
||||
pollStatus?.(null);
|
||||
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
const forced = await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
expect(forced.forgeWeb).toEqual({ host: "github.com", referenceRepository: "main-owner/repo" });
|
||||
}, 30_000);
|
||||
|
||||
test("a successful parent lookup is reused across forced refreshes of the same remote", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
let repositoryViews = 0;
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view") {
|
||||
// The parent lookup is the one that names the origin repository.
|
||||
if (args[2] === "fork-owner/repo") repositoryViews += 1;
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
expect(repositoryViews).toBe(1);
|
||||
});
|
||||
|
||||
test("a fork checked out at a detached HEAD still publishes its parent", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
execFileSync("git", ["checkout", "--detach"], { cwd: repo, stdio: "pipe" });
|
||||
|
||||
const { service, snapshot } = await snapshotOf(repo, {
|
||||
forgeOverrides: {
|
||||
github: createGitHubWithRepositoryView({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.forgeWeb).toEqual({ host: "github.com" });
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
});
|
||||
|
||||
test("a parent lookup that finishes during a refresh keeps its answer", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
const lookupStarted = Promise.withResolvers<void>();
|
||||
const releaseLookup = Promise.withResolvers<void>();
|
||||
const statusLoadStarted = Promise.withResolvers<void>();
|
||||
const releaseStatusLoad = Promise.withResolvers<void>();
|
||||
let holdNextStatusLoad = false;
|
||||
let repositoryViews = 0;
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "auth" && args[1] === "status" && holdNextStatusLoad) {
|
||||
holdNextStatusLoad = false;
|
||||
statusLoadStarted.resolve();
|
||||
await releaseStatusLoad.promise;
|
||||
}
|
||||
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
|
||||
repositoryViews += 1;
|
||||
if (repositoryViews === 1) {
|
||||
lookupStarted.resolve();
|
||||
await releaseLookup.promise;
|
||||
}
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await lookupStarted.promise;
|
||||
|
||||
holdNextStatusLoad = true;
|
||||
const second = service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await statusLoadStarted.promise;
|
||||
|
||||
// The first refresh's lookup finishes while the second refresh is still loading the PR status.
|
||||
releaseLookup.resolve();
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
releaseStatusLoad.resolve();
|
||||
|
||||
const snapshot = await second;
|
||||
expect(snapshot.forgeWeb).toEqual({
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
expect(repositoryViews).toBe(1);
|
||||
}, 30_000);
|
||||
|
||||
test("disposal waits for a background parent lookup", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
const lookupStarted = Promise.withResolvers<void>();
|
||||
const releaseLookup = Promise.withResolvers<void>();
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
|
||||
lookupStarted.resolve();
|
||||
await releaseLookup.promise;
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await lookupStarted.promise;
|
||||
|
||||
let disposed = false;
|
||||
const disposal = service.dispose().then(() => {
|
||||
disposed = true;
|
||||
return undefined;
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(disposed).toBe(false);
|
||||
|
||||
releaseLookup.resolve();
|
||||
await disposal;
|
||||
expect(disposed).toBe(true);
|
||||
} finally {
|
||||
releaseLookup.resolve();
|
||||
await service.dispose();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("the first snapshot publishes git state and pull request status without waiting for the parent lookup", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
writeFileSync(path.join(repo, "changed.txt"), "dirty\n");
|
||||
const lookupStarted = Promise.withResolvers<void>();
|
||||
const releaseLookup = Promise.withResolvers<void>();
|
||||
const pullRequestStatusLoaded = Promise.withResolvers<void>();
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
|
||||
lookupStarted.resolve();
|
||||
await releaseLookup.promise;
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
if (args[0] === "pr" && args[1] === "view") {
|
||||
pullRequestStatusLoaded.resolve();
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
number: 42,
|
||||
url: "https://github.com/fork-owner/repo/pull/42",
|
||||
title: "Already known pull request",
|
||||
state: "OPEN",
|
||||
baseRefName: "main",
|
||||
headRefName: "main",
|
||||
mergedAt: null,
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
|
||||
let snapshot: Awaited<ReturnType<typeof service.getSnapshot>> | null = null;
|
||||
const pending = service
|
||||
.getSnapshot(repo, { force: true, reason: "forge-web-test" })
|
||||
.then((value) => {
|
||||
snapshot = value;
|
||||
return value;
|
||||
});
|
||||
|
||||
await pullRequestStatusLoaded.promise;
|
||||
// The forge lookup is still held here; the snapshot must not wait for it.
|
||||
await expect.poll(() => snapshot !== null, { timeout: 5_000 }).toBe(true);
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(snapshot!.git.currentBranch).toBe("main");
|
||||
expect(snapshot!.git.isDirty).toBe(true);
|
||||
expect(snapshot!.forge.pullRequest?.number).toBe(42);
|
||||
expect(snapshot!.forgeWeb).toEqual({ host: "github.com" });
|
||||
|
||||
await lookupStarted.promise;
|
||||
releaseLookup.resolve();
|
||||
await pending;
|
||||
await expectForgeWebEventually(service, repo, {
|
||||
host: "github.com",
|
||||
referenceRepository: "main-owner/repo",
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test("a failed parent lookup is not repeated by forced refreshes before the retry delay", async () => {
|
||||
const repo = createCheckoutWithOrigin("git@github.com:fork-owner/repo.git");
|
||||
let lookups = 0;
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
forgeOverrides: {
|
||||
github: createGitHubService({
|
||||
resolveGhPath: async () => "gh",
|
||||
resolveRepoHost: async () => null,
|
||||
runner: async (args) => {
|
||||
if (args[0] === "repo" && args[1] === "view" && args[2] === "fork-owner/repo") {
|
||||
lookups += 1;
|
||||
throw new GitHubCommandError({
|
||||
args,
|
||||
cwd: repo,
|
||||
exitCode: 1,
|
||||
stderr: "error connecting to api.github.com",
|
||||
});
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanup.push(() => service.dispose());
|
||||
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await expect.poll(() => lookups, { timeout: 10_000 }).toBe(1);
|
||||
// Let the failed lookup record its retry time.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
await service.getSnapshot(repo, { force: true, reason: "forge-web-test" });
|
||||
|
||||
expect(lookups).toBe(1);
|
||||
}, 30_000);
|
||||
|
||||
@@ -264,6 +264,8 @@ function createSnapshot(cwd: string, overrides?: SnapshotOverrides): WorkspaceGi
|
||||
pullRequest: resolveSnapshotPullRequest(base, overrides),
|
||||
error: resolveSnapshotError(base, overrides),
|
||||
},
|
||||
// Every remote these fixtures use is on github.com.
|
||||
...(forge ? { forgeWeb: { host: "github.com" } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -704,8 +706,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
|
||||
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
|
||||
await refresh;
|
||||
expect(await service.getSnapshot(REPO_CWD)).toEqual(
|
||||
createSnapshot(REPO_CWD, {
|
||||
// A branch change resets the PR status but not where the same remote lives on the web.
|
||||
expect(await service.getSnapshot(REPO_CWD)).toEqual({
|
||||
...createSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "feature",
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
@@ -716,7 +719,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
forgeWeb: { host: "github.com" },
|
||||
});
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ function createSnapshot(
|
||||
pullRequest: resolveSnapshotPullRequest(base, overrides),
|
||||
error: resolveSnapshotError(base, overrides),
|
||||
},
|
||||
// Every remote these fixtures use is on github.com.
|
||||
...(forgeName ? { forgeWeb: { host: "github.com" } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LRUCache } from "lru-cache";
|
||||
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
|
||||
import pLimit from "p-limit";
|
||||
import type pino from "pino";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeWeb, ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import type { CheckoutContext } from "../utils/checkout-git.js";
|
||||
import {
|
||||
@@ -71,6 +71,12 @@ import {
|
||||
type SubscribeToFileChanges,
|
||||
createFileObserver,
|
||||
} from "./file-observer/index.js";
|
||||
import {
|
||||
forgeWebForResolution,
|
||||
resolveForgeWebReference,
|
||||
type ForgeWebEntry,
|
||||
} from "./forge-web.js";
|
||||
import type { SshHostnameResolver } from "../utils/ssh-hostname.js";
|
||||
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
||||
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
|
||||
|
||||
@@ -200,6 +206,8 @@ export interface WorkspaceGitRuntimeSnapshot {
|
||||
} | null;
|
||||
error: { message: string } | null;
|
||||
};
|
||||
/** Absent when the remote resolves to no forge. */
|
||||
forgeWeb?: ForgeWeb;
|
||||
}
|
||||
|
||||
export interface WorkspaceGitService {
|
||||
@@ -406,6 +414,8 @@ interface WorkspaceGitServiceDependencies {
|
||||
* fakes. Any forge not listed here is built (and cached once) by the registry.
|
||||
*/
|
||||
forgeOverrides?: Record<string, ForgeService>;
|
||||
/** Resolves an SSH config alias to its HostName; defaults to `ssh -G`. */
|
||||
resolveSshHostname?: SshHostnameResolver;
|
||||
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
|
||||
hasOriginRemote: (cwd: string) => Promise<boolean>;
|
||||
runGitFetch: (
|
||||
@@ -459,6 +469,11 @@ interface WorkspaceGitTarget {
|
||||
latestGitLoadedAtMs: number | null;
|
||||
latestForge: WorkspaceGitRuntimeSnapshot["forge"] | null;
|
||||
latestForgeLoadedAtMs: number | null;
|
||||
latestForgeWeb: ForgeWebEntry | null;
|
||||
/** The entry whose reference repository is being looked up, so one remote costs one call. */
|
||||
forgeWebLookupEntry: ForgeWebEntry | null;
|
||||
/** The remote the forge snapshot was last refreshed for; null until the first forge refresh. */
|
||||
forgeRefreshedForRemote: { remoteUrl: string | null } | null;
|
||||
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
||||
latestSnapshotLoadedAtMs: number | null;
|
||||
latestFacts: CheckoutSnapshotFacts | null;
|
||||
@@ -647,6 +662,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private disposed = false;
|
||||
private disposePromise: Promise<void> | null = null;
|
||||
private readonly pendingRefreshes = new Set<Promise<WorkspaceGitRuntimeSnapshot>>();
|
||||
private readonly pendingForgeWebLookups = new Set<Promise<void>>();
|
||||
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
|
||||
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
|
||||
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
||||
@@ -691,6 +707,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
);
|
||||
this.forgeResolver = createForgeResolver({
|
||||
createService: (forge) => this.deps.forgeOverrides?.[forge] ?? createForgeService(forge),
|
||||
resolveSshHostname: this.deps.resolveSshHostname,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1140,11 +1157,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.workingTreeWatchAliases.clear();
|
||||
this.snapshotUpdatedListeners.clear();
|
||||
// Unsubscribing can remove a target while its Git command still owns the cwd.
|
||||
// Stop admission first, then drain those refreshes before releasing the service.
|
||||
this.disposePromise = Promise.all([
|
||||
this.fileObserver.close(),
|
||||
Promise.allSettled(this.pendingRefreshes),
|
||||
]).then(() => undefined);
|
||||
// Stop admission first, then drain those refreshes and the parent lookups they start.
|
||||
this.disposePromise = (async () => {
|
||||
await Promise.all([this.fileObserver.close(), Promise.allSettled(this.pendingRefreshes)]);
|
||||
await Promise.allSettled(this.pendingForgeWebLookups);
|
||||
})();
|
||||
return this.disposePromise;
|
||||
}
|
||||
|
||||
@@ -1301,6 +1318,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
latestGit: null,
|
||||
latestGitLoadedAtMs: null,
|
||||
latestForge: null,
|
||||
latestForgeWeb: null,
|
||||
forgeWebLookupEntry: null,
|
||||
forgeRefreshedForRemote: null,
|
||||
latestForgeLoadedAtMs: null,
|
||||
latestSnapshot: null,
|
||||
latestSnapshotLoadedAtMs: null,
|
||||
@@ -3568,6 +3588,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
if (request.includeForge) {
|
||||
await this.refreshForgeSnapshot(target, request, facts, runRefreshGitCommand);
|
||||
} else if (this.hasRemoteChangedSinceForgeRefresh(target)) {
|
||||
// `git remote set-url` reaches here as a git-only refresh. The forge, its host and the
|
||||
// fork's parent all belong to the old remote, so resolve the new one now.
|
||||
await this.refreshForgeSnapshot(
|
||||
target,
|
||||
{ ...request, includeForge: true, force: true },
|
||||
facts,
|
||||
runRefreshGitCommand,
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = this.combineSnapshot(target);
|
||||
@@ -3669,6 +3698,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const loadedAtMs = this.deps.now().getTime();
|
||||
target.latestGitLoadedAtMs = loadedAtMs;
|
||||
target.latestForge = buildForgeUnavailableSnapshot();
|
||||
target.latestForgeWeb = null;
|
||||
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
|
||||
return facts;
|
||||
}
|
||||
@@ -3713,6 +3743,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return facts;
|
||||
}
|
||||
|
||||
private hasRemoteChangedSinceForgeRefresh(target: WorkspaceGitTarget): boolean {
|
||||
const refreshedFor = target.forgeRefreshedForRemote;
|
||||
return (
|
||||
refreshedFor !== null &&
|
||||
target.latestGit?.isGit === true &&
|
||||
refreshedFor.remoteUrl !== target.latestGit.remoteUrl
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshForgeSnapshot(
|
||||
target: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
@@ -3720,13 +3759,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
runRefreshGitCommand: RunGitCommand,
|
||||
): Promise<void> {
|
||||
const remoteUrl = target.latestGit?.remoteUrl ?? null;
|
||||
target.forgeRefreshedForRemote = { remoteUrl };
|
||||
const resolution = await this.forgeResolver.resolveFromRemoteUrlAsync(remoteUrl);
|
||||
// Every forge gates on the resolver alone: a cloud host matches synchronously
|
||||
// and a self-hosted/Enterprise host is recognized by the adapter probe (which
|
||||
// this async resolution populates), so GitHub Enterprise is no longer gated
|
||||
// out by a cloud-only identity check.
|
||||
if (!resolution) {
|
||||
if (!resolution || !remoteUrl) {
|
||||
target.latestForge = buildUnresolvedRemoteForgeSnapshot(remoteUrl);
|
||||
target.latestForgeWeb = null;
|
||||
target.latestForgeLoadedAtMs = this.deps.now().getTime();
|
||||
return;
|
||||
}
|
||||
@@ -3749,7 +3790,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
// Carry the resolved forge (probe-aware) so the wire projection labels
|
||||
// self-managed GitLab hosts correctly instead of falling back to "github".
|
||||
target.latestForge = { ...forgeSnapshot, forge: resolution.forge };
|
||||
// Publish the host with the snapshot; the parent lookup finishes in the background. Read the
|
||||
// previous entry after the wait: a lookup for the old entry may have completed meanwhile.
|
||||
target.latestForgeWeb = forgeWebForResolution({
|
||||
remoteUrl,
|
||||
resolution,
|
||||
previous: target.latestForgeWeb,
|
||||
nowMs: this.deps.now().getTime(),
|
||||
});
|
||||
target.latestForgeLoadedAtMs = this.deps.now().getTime();
|
||||
this.queueForgeWebReferenceLookup(target);
|
||||
}
|
||||
|
||||
private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot {
|
||||
@@ -3761,6 +3811,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
cwd: target.cwd,
|
||||
git: target.latestGit,
|
||||
forge: target.latestForge ?? buildForgeUnavailableSnapshot(),
|
||||
// Only the remote it was resolved for: after `git remote set-url` the old host and
|
||||
// parent are wrong until the next forge refresh resolves the new remote.
|
||||
...(target.latestForgeWeb?.remoteUrl === target.latestGit.remoteUrl
|
||||
? { forgeWeb: target.latestForgeWeb.forgeWeb }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3800,6 +3855,68 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.rememberSnapshot(target, this.combineSnapshot(target), {
|
||||
forceEmit: false,
|
||||
});
|
||||
this.queueForgeWebReferenceLookup(target);
|
||||
}
|
||||
|
||||
private queueForgeWebReferenceLookup(target: WorkspaceGitTarget): void {
|
||||
const lookup = this.resolveForgeWebReferenceIfDue(target)
|
||||
.catch((error) => {
|
||||
if (!this.disposed) {
|
||||
this.logger.warn(
|
||||
{ err: error, cwd: target.cwd },
|
||||
"Failed to resolve the forge reference repository",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingForgeWebLookups.delete(lookup);
|
||||
});
|
||||
this.pendingForgeWebLookups.add(lookup);
|
||||
}
|
||||
|
||||
// The status poll is the only periodic work an idle workspace has, so it also
|
||||
// retries a parent lookup that failed or ran before the forge CLI was signed in.
|
||||
private async resolveForgeWebReferenceIfDue(target: WorkspaceGitTarget): Promise<void> {
|
||||
const entry = target.latestForgeWeb;
|
||||
const remoteUrl = target.latestGit?.remoteUrl ?? null;
|
||||
const nowMs = this.deps.now().getTime();
|
||||
if (
|
||||
!entry ||
|
||||
!remoteUrl ||
|
||||
entry.remoteUrl !== remoteUrl ||
|
||||
entry.retryAtMs === null ||
|
||||
nowMs < entry.retryAtMs ||
|
||||
target.forgeWebLookupEntry === entry
|
||||
) {
|
||||
return;
|
||||
}
|
||||
target.forgeWebLookupEntry = entry;
|
||||
try {
|
||||
// The async resolver, like the forge refresh: only it reports the `ssh -G` host for an alias.
|
||||
const resolution = await this.forgeResolver.resolveFromRemoteUrlAsync(remoteUrl);
|
||||
if (!resolution) {
|
||||
return;
|
||||
}
|
||||
const next = await resolveForgeWebReference({
|
||||
cwd: target.cwd,
|
||||
resolution,
|
||||
entry,
|
||||
nowMs,
|
||||
});
|
||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||
return;
|
||||
}
|
||||
// The origin can change while the lookup runs; the old remote's answer is not ours to publish.
|
||||
if (target.latestForgeWeb !== entry || target.latestGit?.remoteUrl !== entry.remoteUrl) {
|
||||
return;
|
||||
}
|
||||
target.latestForgeWeb = next;
|
||||
this.rememberSnapshot(target, this.combineSnapshot(target), { forceEmit: false });
|
||||
} finally {
|
||||
if (target.forgeWebLookupEntry === entry) {
|
||||
target.forgeWebLookupEntry = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private rememberSnapshot(
|
||||
|
||||
@@ -467,6 +467,13 @@ export interface ForgeService {
|
||||
* Others reject a cross-repo checkout that carries no refs.
|
||||
*/
|
||||
supportsCrossRepoCheckoutWithoutRefs?: boolean;
|
||||
/**
|
||||
* Repository path that owns the issue and change-request numbers of the
|
||||
* checkout's origin repository when that is a different repository, such as a
|
||||
* fork's parent. Null when the origin owns its numbers. Adapters whose forge
|
||||
* has no fork model omit it.
|
||||
*/
|
||||
getReferenceRepository?(options: { cwd: string } & ForgeReadOptions): Promise<string | null>;
|
||||
getCurrentPullRequestStatus(
|
||||
options: {
|
||||
cwd: string;
|
||||
|
||||
@@ -429,6 +429,41 @@ function giteaMergeStatus(
|
||||
};
|
||||
}
|
||||
|
||||
describe("createGiteaService getReferenceRepository", () => {
|
||||
it("names the repository a fork was forked from, asking about the origin repository explicitly", async () => {
|
||||
const { service, calls } = makeService(() =>
|
||||
ok(
|
||||
JSON.stringify({
|
||||
full_name: "example-user/sample-repo",
|
||||
fork: true,
|
||||
parent: { full_name: "upstream-org/sample-repo" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBe(
|
||||
"upstream-org/sample-repo",
|
||||
);
|
||||
expect(calls).toEqual([["api", "repos/example-user/sample-repo"]]);
|
||||
});
|
||||
|
||||
it("rejects when tea api answers with an error body, since tea exits 0 on HTTP errors", async () => {
|
||||
const { service } = makeService(() =>
|
||||
ok(JSON.stringify({ message: "user does not exist [uid: 0, name: ]" })),
|
||||
);
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("is null for a repository that is not a fork", async () => {
|
||||
const { service } = makeService(() =>
|
||||
ok(JSON.stringify({ full_name: "example-user/sample-repo", fork: false, parent: null })),
|
||||
);
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createGiteaService", () => {
|
||||
it("maps a tea pr list item to the neutral current PR status by head branch", async () => {
|
||||
const { service, calls } = makeService((args) => {
|
||||
|
||||
@@ -247,6 +247,14 @@ const GiteaPullRequestApiSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// `full_name` is required so an HTTP error body, which `tea api` returns with exit code 0, fails the parse.
|
||||
const GiteaRepositoryApiSchema = z
|
||||
.object({
|
||||
full_name: z.string(),
|
||||
parent: z.object({ full_name: z.string().optional() }).nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const GiteaCurrentPullRequestApiSchema = z
|
||||
.object({
|
||||
number: z.number(),
|
||||
@@ -2007,6 +2015,19 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
}
|
||||
},
|
||||
|
||||
async getReferenceRepository(input): Promise<string | null> {
|
||||
const repo = await resolveCurrentRepoIdentity(input.cwd);
|
||||
if (!repo) {
|
||||
return null;
|
||||
}
|
||||
const repository = await runJson(
|
||||
["api", `repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`],
|
||||
{ cwd: input.cwd },
|
||||
GiteaRepositoryApiSchema,
|
||||
);
|
||||
return repository.parent?.full_name ?? null;
|
||||
},
|
||||
|
||||
getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
|
||||
return loadCurrentPullRequestStatus({
|
||||
cwd: input.cwd,
|
||||
|
||||
@@ -528,6 +528,65 @@ describe("ForgeService", () => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
describe("getReferenceRepository", () => {
|
||||
const forkView = JSON.stringify({
|
||||
owner: { login: "fork-owner" },
|
||||
name: "repo",
|
||||
parent: { owner: { login: "main-owner" }, name: "repo" },
|
||||
});
|
||||
|
||||
it("names the parent of a fork checkout, asking about the origin repository explicitly", async () => {
|
||||
const runner = createRunner([forkView]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveRepoHost: async () => null,
|
||||
resolveOriginRepository: async () => "fork-owner/repo",
|
||||
});
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/tmp/repo" })).resolves.toBe(
|
||||
"main-owner/repo",
|
||||
);
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(runner.calls[0].args).toEqual([
|
||||
"repo",
|
||||
"view",
|
||||
"fork-owner/repo",
|
||||
"--json",
|
||||
"owner,name,parent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects when gh cannot answer, so a failed lookup is not mistaken for a repository without a parent", async () => {
|
||||
const service = createGitHubService({
|
||||
runner: async () => {
|
||||
throw new GitHubCommandError({
|
||||
args: ["repo", "view"],
|
||||
cwd: "/tmp/repo",
|
||||
exitCode: 1,
|
||||
stderr: "error connecting to api.github.com",
|
||||
});
|
||||
},
|
||||
resolveRepoHost: async () => null,
|
||||
resolveOriginRepository: async () => "fork-owner/repo",
|
||||
});
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/tmp/repo" })).rejects.toBeInstanceOf(
|
||||
GitHubCommandError,
|
||||
);
|
||||
});
|
||||
|
||||
it("is null for a repository that is not a fork", async () => {
|
||||
const runner = createRunner([JSON.stringify({ owner: { login: "acme" }, name: "repo" })]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveRepoHost: async () => null,
|
||||
resolveOriginRepository: async () => "acme/repo",
|
||||
});
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/tmp/repo" })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
isGitHubHost,
|
||||
parseGitHubRemoteIdentity,
|
||||
parseGitHubRemoteUrl,
|
||||
parseGitRemoteLocation,
|
||||
} from "@getpaseo/protocol/git-remote";
|
||||
@@ -959,6 +960,11 @@ interface GitHubServiceDependencies {
|
||||
* per invocation.
|
||||
*/
|
||||
resolveRepoSlug: (cwd: string) => Promise<string | null>;
|
||||
/**
|
||||
* owner/name of the origin remote on any host: an SSH alias or Enterprise
|
||||
* host included, which `resolveRepoSlug` leaves to `gh`.
|
||||
*/
|
||||
resolveOriginRepository: (cwd: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface GitHubCommandRunnerOptions {
|
||||
@@ -1050,6 +1056,7 @@ interface CreateGitHubServiceOptions {
|
||||
now?: () => number;
|
||||
resolveRepoHost?: (cwd: string) => Promise<string | null>;
|
||||
resolveRepoSlug?: (cwd: string) => Promise<string | null>;
|
||||
resolveOriginRepository?: (cwd: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
type PullRequestCheckRunNode = z.infer<typeof PullRequestCheckRunNodeSchema>;
|
||||
@@ -1132,6 +1139,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
now: options.now ?? Date.now,
|
||||
resolveRepoHost: options.resolveRepoHost ?? resolveGitHubEnterpriseHost,
|
||||
resolveRepoSlug: options.resolveRepoSlug ?? resolveGitHubSlugFromOrigin,
|
||||
resolveOriginRepository: options.resolveOriginRepository ?? resolveOriginRepositoryPath,
|
||||
};
|
||||
// A resolved enterprise host is cached permanently; a null resolution (no
|
||||
// host, or the auth probe said no) expires so `gh auth login --hostname`
|
||||
@@ -2543,6 +2551,26 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
getReferenceRepository(input) {
|
||||
return cached({
|
||||
cwd: input.cwd,
|
||||
method: "getReferenceRepository",
|
||||
args: {},
|
||||
readOptions: input,
|
||||
load: async () => {
|
||||
const repo = await deps.resolveOriginRepository(input.cwd);
|
||||
if (!repo) {
|
||||
return null;
|
||||
}
|
||||
// A failed lookup must reject: "no parent" is cached as a final answer.
|
||||
const view = await readGitHubRepoView({ cwd: input.cwd, repo, run });
|
||||
const parentOwner = view.parent?.owner?.login;
|
||||
const parentName = view.parent?.name;
|
||||
return parentOwner && parentName ? `${parentOwner}/${parentName}` : null;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
isAuthenticated(input) {
|
||||
return cached({
|
||||
cwd: input.cwd,
|
||||
@@ -3224,20 +3252,53 @@ async function resolveGitHubSlugFromOrigin(cwd: string): Promise<string | null>
|
||||
return parseGitHubRemoteUrl(stdout.trim())?.repo ?? null;
|
||||
}
|
||||
|
||||
async function getGitHubRepoView(options: {
|
||||
cwd: string;
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
}): Promise<z.infer<typeof GitHubRepoViewSchema> | null> {
|
||||
const args = ["repo", "view", "--json", "owner,name,parent"];
|
||||
async function resolveOriginRepositoryPath(cwd: string): Promise<string | null> {
|
||||
let stdout: string;
|
||||
try {
|
||||
const stdout = await options.run(args, {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
return parseGitHubJsonOutput(stdout, GitHubRepoViewSchema, {
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
emptyFallback: "{}",
|
||||
});
|
||||
({ stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
|
||||
cwd,
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
||||
timeout: GIT_ORIGIN_URL_READ_TIMEOUT_MS,
|
||||
}));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const location = parseGitRemoteLocation(stdout.trim());
|
||||
return location ? (parseGitHubRemoteIdentity(location.path)?.repo ?? null) : null;
|
||||
}
|
||||
|
||||
interface GitHubRepoViewOptions {
|
||||
cwd: string;
|
||||
/** owner/name to ask about; `gh` picks the default repository for the directory when absent. */
|
||||
repo?: string;
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
}
|
||||
|
||||
async function readGitHubRepoView(
|
||||
options: GitHubRepoViewOptions,
|
||||
): Promise<z.infer<typeof GitHubRepoViewSchema>> {
|
||||
const args = [
|
||||
"repo",
|
||||
"view",
|
||||
...(options.repo ? [options.repo] : []),
|
||||
"--json",
|
||||
"owner,name,parent",
|
||||
];
|
||||
const stdout = await options.run(args, {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
return parseGitHubJsonOutput(stdout, GitHubRepoViewSchema, {
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
emptyFallback: "{}",
|
||||
});
|
||||
}
|
||||
|
||||
async function getGitHubRepoView(
|
||||
options: GitHubRepoViewOptions,
|
||||
): Promise<z.infer<typeof GitHubRepoViewSchema> | null> {
|
||||
try {
|
||||
return await readGitHubRepoView(options);
|
||||
} catch (error) {
|
||||
// A missing CLI or an auth failure must surface as its typed class so the
|
||||
// caller reports the real problem; only a genuine "not a resolvable repo"
|
||||
|
||||
@@ -260,6 +260,38 @@ const DISCUSSIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
describe("createGitLabService getReferenceRepository", () => {
|
||||
it("names the project a fork was forked from, asking about the origin project explicitly", async () => {
|
||||
const { service, calls } = makeService(() =>
|
||||
ok(
|
||||
JSON.stringify({
|
||||
path_with_namespace: "example-group/example-project",
|
||||
forked_from_project: { path_with_namespace: "upstream-group/sub/example-project" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBe(
|
||||
"upstream-group/sub/example-project",
|
||||
);
|
||||
expect(calls).toEqual([["api", "projects/example-group%2Fexample-project"]]);
|
||||
});
|
||||
|
||||
it("rejects when glab api answers with an error body", async () => {
|
||||
const { service } = makeService(() => ok(JSON.stringify({ message: "404 Project Not Found" })));
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("is null for a project that is not a fork", async () => {
|
||||
const { service } = makeService(() =>
|
||||
ok(JSON.stringify({ path_with_namespace: "example-group/example-project" })),
|
||||
);
|
||||
|
||||
await expect(service.getReferenceRepository?.({ cwd: "/repo" })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createGitLabService", () => {
|
||||
it("maps a glab merge request view to the neutral current PR status", async () => {
|
||||
const { service, calls } = makeService((args) =>
|
||||
|
||||
@@ -249,6 +249,16 @@ const GitLabApprovalsSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const GitLabProjectSchema = z
|
||||
.object({
|
||||
path_with_namespace: z.string(),
|
||||
forked_from_project: z
|
||||
.object({ path_with_namespace: z.string().optional() })
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type GitLabMergeRequest = z.infer<typeof GitLabMergeRequestSchema>;
|
||||
type GitLabIssue = z.infer<typeof GitLabIssueSchema>;
|
||||
type GitLabNote = z.infer<typeof GitLabNoteSchema>;
|
||||
@@ -1151,6 +1161,20 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
|
||||
}
|
||||
},
|
||||
|
||||
async getReferenceRepository(input): Promise<string | null> {
|
||||
const remoteUrl = await resolveRemoteUrl(input.cwd);
|
||||
const projectPath = remoteUrl ? parseGitRemoteLocation(remoteUrl)?.path : null;
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
const project = await runJson(
|
||||
["api", `projects/${encodeURIComponent(projectPath)}`],
|
||||
{ cwd: input.cwd },
|
||||
GitLabProjectSchema,
|
||||
);
|
||||
return project.forked_from_project?.path_with_namespace ?? null;
|
||||
},
|
||||
|
||||
async getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
|
||||
try {
|
||||
const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef, input.headSha);
|
||||
|
||||
@@ -23,8 +23,9 @@ export default function contribute(server: PluginServerContext) {
|
||||
return;
|
||||
}
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
|
||||
const text = latestOutputText(page.entries.map(({ item }) => item));
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
|
||||
const text = latestOutputText(turn.map(({ item }) => item));
|
||||
if (/out of credits/i.test(text)) {
|
||||
console.log("Sending a follow-up after out of credits", event.agent.id);
|
||||
await agent.send("Try again.");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user