((resolve) => {
+ settles.push(resolve)
+ })
+ )
+
+ const mergeButton = () =>
+ screen.getByRole("button", { name: /^(Merge|Merging…)$/ })
+ const { view } = mount(row({ is_pr: true }), null, {
+ folderId: 7,
+ repo: "github.com/me/codeg",
+ })
+ await waitFor(() => expect(mergeButton()).toBeEnabled())
+
+ view.rerender(
+ panelWithRepo(row({ is_pr: true }), 7, "github.com/acme/codeg-parent")
+ )
+
+ // Blanked rather than carried over: the method the fork prefers is a claim
+ // about a repository this panel is no longer reading, and the box says so
+ // by not being willing to merge until the new repository has answered.
+ await waitFor(() => expect(forgeMergeOptions).toHaveBeenCalledTimes(2))
+ expect(mergeButton()).toBeDisabled()
+
+ // And the parent's own answer still lands when it comes.
+ settles[0]?.({
+ methods: ["merge"],
+ default_method: "merge",
+ merge_strategy: "merge_commit",
+ })
+ await waitFor(() => expect(mergeButton()).toBeEnabled())
+ })
})
/**
diff --git a/src/components/forge/forge-issue-detail-sheet.tsx b/src/components/forge/forge-issue-detail-sheet.tsx
index 02a7015763..9833c9d3d3 100644
--- a/src/components/forge/forge-issue-detail-sheet.tsx
+++ b/src/components/forge/forge-issue-detail-sheet.tsx
@@ -96,6 +96,7 @@ import {
forgeSetItemState,
} from "@/lib/api"
import {
+ isForgeWriteMismatch,
type AppErrorTranslator,
toLocalizedErrorMessage,
} from "@/lib/app-error"
@@ -110,6 +111,7 @@ import type {
ForgeCheckList,
ForgeCheckState,
ForgeComment,
+ ForgeExpectedRepo,
ForgeIdentity,
ForgeIssueRow,
ForgeMergeMethod,
@@ -275,6 +277,8 @@ function CommentThread({
kind,
number,
identity,
+ expected,
+ onStaleRepository,
onPosted,
beforeComposer,
viewportRef,
@@ -287,6 +291,12 @@ function CommentThread({
* thread is keyed by the ITEM and remounts as the reader clicks the list,
* while the identity is a property of the folder. */
identity: ForgeIdentity | null
+ /** The repository on screen, passed to the composer so a post is REFUSED
+ * rather than landing in whichever repository the folder reads by the time
+ * it arrives. See [`CommentComposer`]. */
+ expected: ForgeExpectedRepo | null
+ /** That refusal reached the composer: the page re-resolves. */
+ onStaleRepository: () => void
/** A comment landed on the forge, and here it is. The caller bumps the
* item's count so the header stops trailing the thread underneath it. */
onPosted: (comment: ForgeComment) => void
@@ -487,6 +497,8 @@ function CommentThread({
kind={kind}
number={number}
identity={identity}
+ expected={expected}
+ onStaleRepository={onStaleRepository}
onPosted={(comment) => {
// Into its own slot, not into the paged collection — see `posted`
// for why that ordering and that race both matter. Nothing is
@@ -674,6 +686,8 @@ function CommentComposer({
kind,
number,
identity,
+ expected,
+ onStaleRepository,
onPosted,
}: {
folderId: number
@@ -682,6 +696,13 @@ function CommentComposer({
/** Who the comment would be signed as, or `null` while that is still being
* resolved — or could not be. See [`useForgeIdentity`]. */
identity: ForgeIdentity | null
+ /** The repository the panel is showing, so the post can be REFUSED rather
+ * than land in whichever one the folder's selection names by the time it
+ * arrives. `null` = name nothing, which is the old behaviour. */
+ expected: ForgeExpectedRepo | null
+ /** The refusal above means the panel is stale: hand it to the page, which
+ * re-resolves and tears this panel down with it. */
+ onStaleRepository: () => void
onPosted: (comment: ForgeComment) => void
}) {
const t = useTranslations("Forge")
@@ -698,21 +719,46 @@ function CommentComposer({
setPosting(true)
setFailure(null)
try {
- const comment = await forgeCreateComment(folderId, {
- kind,
- number,
- body: trimmed,
- })
+ const comment = await forgeCreateComment(
+ folderId,
+ {
+ kind,
+ number,
+ body: trimmed,
+ },
+ expected
+ )
// Only now — a draft cleared before the answer would lose what somebody
// wrote to a network failure they cannot retry from.
setBody("")
onPosted(comment)
} catch (error) {
+ if (isForgeWriteMismatch(error)) {
+ // The folder has moved to another repository, so this panel is stale
+ // and the page is about to re-resolve — which unmounts this composer
+ // and the strip with it. A toast survives the teardown, so the reason
+ // is still readable after the panel it belonged to is gone.
+ toast.error(
+ toLocalizedErrorMessage(error, tRoot as unknown as AppErrorTranslator)
+ )
+ onStaleRepository()
+ return
+ }
setFailure({ error })
} finally {
setPosting(false)
}
- }, [folderId, kind, number, onPosted, posting, trimmed])
+ }, [
+ expected,
+ folderId,
+ kind,
+ number,
+ onPosted,
+ onStaleRepository,
+ posting,
+ tRoot,
+ trimmed,
+ ])
return (
@@ -846,6 +892,11 @@ type FileStatusLabelKey =
const RAIL = "flex gap-2.5"
const RAIL_BODY = "min-w-0 flex-1"
+/** Stands in for a caller that wired no re-resolve — a fixture, a preview. A
+ * fresh arrow per render would also invalidate every `useCallback` that
+ * depends on it, which is the other reason this is one shared value. */
+const NO_OP = () => {}
+
/**
* The gutter's own column, and what pins what sits in it.
*
@@ -1867,29 +1918,34 @@ function mergeMethodText(
*/
function useForgeIdentity(
folderId: number | null,
+ repo: string | null | undefined,
enabled: boolean
): ForgeIdentity | null {
const [identity, setIdentity] = useState(null)
- /** The folder the answer above describes. */
- const [shown, setShown] = useState(null)
+ /** The folder AND remote the answer above describes. Both, because the
+ * folder picks the repository only until the picker points it somewhere
+ * else — the account belongs to the repository, and one folder can name
+ * several in turn. */
+ const [shown, setShown] = useState(null)
const reqRef = useRef(0)
+ const key = `${folderId}:${repo}`
// Absorbed during RENDER, as [`useMergeOptions`] does: an effect would commit
// one frame naming the account of the repository the panel just left. Keyed
- // on the FOLDER alone, so closing the panel keeps the answer rather than
- // blanking the avatar every time it is reopened.
- if (folderId !== shown) {
- setShown(folderId)
+ // on the repository rather than on the folder alone — so closing the panel
+ // keeps the answer rather than blanking the avatar every time it is reopened,
+ // while a switch to another remote DOES blank it.
+ if (key !== shown) {
+ setShown(key)
setIdentity(null)
}
useEffect(() => {
// Claimed BEFORE the early return, so a run that asks for nothing still
// invalidates whatever the last one had in flight. Otherwise a lookup for
- // the folder the panel was last opened on lands after the reader has
- // switched repositories — the reset above has already been and gone by
- // then, because it keys on the folder and the folder stopped changing —
- // and the next open names an account from the repository before this one.
+ // the repository the panel was last opened on lands after the reader has
+ // switched to another — the reset above has already been and gone by then
+ // — and the next open names an account from the repository before this one.
const id = ++reqRef.current
if (folderId == null || !enabled) return
void forgeIdentity(folderId)
@@ -1899,7 +1955,7 @@ function useForgeIdentity(
.catch(() => {
if (id === reqRef.current) setIdentity(null)
})
- }, [folderId, enabled])
+ }, [folderId, repo, enabled])
return identity
}
@@ -1924,18 +1980,21 @@ const FALLBACK_METHODS: readonly ForgeMergeMethod[] = ["merge"]
*/
function useMergeOptions(
folderId: number | null,
+ repo: string | null | undefined,
enabled: boolean
): ForgeMergeOptions | null {
const [options, setOptions] = useState(null)
- /** The folder the answer above describes, so a folder switch cannot leave
- * one repository's permitted methods on another's button. */
- const [shown, setShown] = useState(null)
+ /** The folder AND remote the answer above describes, so neither a folder
+ * switch nor a switch of the remote within it can leave one repository's
+ * permitted methods on another's button. */
+ const [shown, setShown] = useState(null)
const reqRef = useRef(0)
+ const key = `${folderId}:${repo}`
// Absorbed during RENDER, the same rule `useChangeDetail` follows: an effect
// would commit one frame of the previous repository's menu.
- if (folderId !== shown) {
- setShown(folderId)
+ if (key !== shown) {
+ setShown(key)
setOptions(null)
}
@@ -1955,7 +2014,7 @@ function useMergeOptions(
})
}
})
- }, [folderId, enabled])
+ }, [folderId, repo, enabled])
return options
}
@@ -2209,6 +2268,9 @@ function MergeBox({
function Conversation({
row,
folderId,
+ repo,
+ expected,
+ onStaleRepository,
identity,
onCommentPosted,
beforeComposer,
@@ -2217,6 +2279,13 @@ function Conversation({
}: {
row: ForgeIssueRow
folderId: number | null
+ /** The repository `folderId` is pointed at — part of the thread's key, so a
+ * switch of the remote resets it exactly as a switch of the item does. */
+ repo?: string | null
+ /** The same repository as the pair every write carries. See
+ * [`ForgeIssueDetailSheet`] — this only passes it down. */
+ expected: ForgeExpectedRepo | null
+ onStaleRepository: () => void
/** Who a comment from here would be signed as — see [`useForgeIdentity`]. */
identity: ForgeIdentity | null
onCommentPosted: (item: { isPr: boolean; number: number }) => void
@@ -2267,20 +2336,25 @@ function Conversation({
- {/* Keyed by the ITEM, not by the row object: the page re-reads the row
- from the list on every render, so identity changes whenever anything
- behind the panel refreshes — and a thread that remounted on each of
- those would re-fetch, lose its loaded pages and scroll the reader back
- to the top. The panel is non-modal, though, so clicking a different
- row swaps the item underneath without ever closing; the key is what
- resets it when that happens. */}
+ {/* Keyed by the ITEM *and the repository*, not by the row object: the page
+ re-reads the row from the list on every render, so identity changes
+ whenever anything behind the panel refreshes — and a thread that
+ remounted on each of those would re-fetch, lose its loaded pages and
+ scroll the reader back to the top. The panel is non-modal, though, so
+ clicking a different row swaps the item underneath without ever
+ closing; the key is what resets it when that happens. The repository
+ belongs in the key for the same reason the item does — the same number
+ names a different item in another repository, and its comments would
+ be the wrong thread entirely. */}
{folderId != null ? (
void
onOpenChange: (open: boolean) => void
/** Opens the page's trigger dialog on this item. */
onStart: () => void
@@ -2440,11 +2545,11 @@ export function ForgeIssueDetailSheet({
change != null &&
row?.state === "open" &&
(detail.detail?.state ?? "open") === "open"
- const mergeOptions = useMergeOptions(change?.folderId ?? null, canMerge)
+ const mergeOptions = useMergeOptions(change?.folderId ?? null, repo, canMerge)
/** Held HERE, above the thread that remounts per item — the account is a
- * property of the folder, not of the item being read. Gated on the panel
+ * property of the repository, not of the item being read. Gated on the panel
* being open, because the drawer is mounted whether or not it is. */
- const identity = useForgeIdentity(folderId, row != null)
+ const identity = useForgeIdentity(folderId, repo, row != null)
/**
* The element the conversation is scrolled in, for the virtualized thread
@@ -2497,11 +2602,15 @@ export function ForgeIssueDetailSheet({
if (row == null || folderId == null) return
setChanging(true)
try {
- const updated = await forgeSetItemState(folderId, {
- kind: row.is_pr ? "pr" : "issue",
- number: row.number,
- action,
- })
+ const updated = await forgeSetItemState(
+ folderId,
+ {
+ kind: row.is_pr ? "pr" : "issue",
+ number: row.number,
+ action,
+ },
+ expected
+ )
setPendingAction(null)
onRowUpdated(mergeForgeRowUpdate(row, updated))
} catch (error) {
@@ -2510,11 +2619,14 @@ export function ForgeIssueDetailSheet({
toast.error(
toLocalizedErrorMessage(error, tRoot as unknown as AppErrorTranslator)
)
+ // Refused because the folder has moved on: the page re-resolves, and
+ // this panel goes with it. The toast above outlives both.
+ if (isForgeWriteMismatch(error)) onStaleRepository()
} finally {
setChanging(false)
}
},
- [folderId, onRowUpdated, row, tRoot]
+ [expected, folderId, onRowUpdated, onStaleRepository, row, tRoot]
)
const reloadDetail = detail.reload
@@ -2523,16 +2635,20 @@ export function ForgeIssueDetailSheet({
if (row == null || folderId == null) return
setMerging(true)
try {
- const updated = await forgeMergeChange(folderId, {
- number: row.number,
- method: pending.method,
- // The commit the DIALOG was armed with, not whatever the panel holds
- // now. The diff, the file list and the checks all describe that one,
- // so a merge that quietly landed a newer one would land code nobody
- // in this conversation ever saw. Both forges answer 409 if the branch
- // has moved, in their own words.
- headSha: pending.headSha,
- })
+ const updated = await forgeMergeChange(
+ folderId,
+ {
+ number: row.number,
+ method: pending.method,
+ // The commit the DIALOG was armed with, not whatever the panel holds
+ // now. The diff, the file list and the checks all describe that one,
+ // so a merge that quietly landed a newer one would land code nobody
+ // in this conversation ever saw. Both forges answer 409 if the branch
+ // has moved, in their own words.
+ headSha: pending.headSha,
+ },
+ expected
+ )
setPendingMerge(null)
// `null` is "it merged, and the row could not be read back" — GitHub's
// merge response does not contain the pull request, so the row costs a
@@ -2560,6 +2676,11 @@ export function ForgeIssueDetailSheet({
toast.error(
toLocalizedErrorMessage(error, tRoot as unknown as AppErrorTranslator)
)
+ // A refusal because the folder has moved on is the one failure here
+ // the page can FIX (see the prop's note) — and it must, because every
+ // later action in this panel would be aimed at the same stale
+ // repository.
+ if (isForgeWriteMismatch(error)) onStaleRepository()
// The confirmation is DISMISSED on failure, unlike the close/reopen
// one that stays put. It has to be: the likeliest refusal is "Head
// branch was modified. Review and try the merge again.", and the whole
@@ -2575,7 +2696,16 @@ export function ForgeIssueDetailSheet({
setMerging(false)
}
},
- [folderId, onRowUpdated, reloadDetail, row, t, tRoot]
+ [
+ expected,
+ folderId,
+ onRowUpdated,
+ onStaleRepository,
+ reloadDetail,
+ row,
+ t,
+ tRoot,
+ ]
)
if (row == null) return null
@@ -2707,6 +2837,9 @@ export function ForgeIssueDetailSheet({
= {}): ForgeIssueRow {
}
}
-function mount(labelOptions: ForgeLabel[] = []) {
+function mount(
+ labelOptions: ForgeLabel[] = [],
+ handlers: {
+ expected?: ForgeExpectedRepo | null
+ onStaleRepository?: () => void
+ } = {}
+) {
const onOpenChange = vi.fn()
const onCreated = vi.fn()
+ const onStaleRepository = handlers.onStaleRepository ?? vi.fn()
render(
)
- return { onOpenChange, onCreated }
+ return { onOpenChange, onCreated, onStaleRepository }
}
beforeEach(() => {
@@ -112,13 +121,19 @@ describe("ForgeNewIssueDialog", () => {
await user.click(screen.getByRole("button", { name: "Create issue" }))
await waitFor(() =>
- expect(forgeCreateIssue).toHaveBeenCalledWith(7, {
- title: "Login times out",
- // Null, not "": GitHub stores an empty string as a body and the issue
- // then renders an empty description block.
- body: null,
- labels: ["bug"],
- })
+ expect(forgeCreateIssue).toHaveBeenCalledWith(
+ 7,
+ {
+ title: "Login times out",
+ // Null, not "": GitHub stores an empty string as a body and the issue
+ // then renders an empty description block.
+ body: null,
+ labels: ["bug"],
+ },
+ // No repository named by this caller — the page passes one in
+ // production (see the test below).
+ null
+ )
)
// The forge's row — the number and the URL only exist once it is written.
expect(onCreated).toHaveBeenCalledWith(
@@ -147,6 +162,56 @@ describe("ForgeNewIssueDialog", () => {
// A picker that can only ever open an empty list is worse than no picker.
expect(screen.queryByText("Labels")).not.toBeInTheDocument()
})
+
+ it("carries the repository the dialog was opened over", async () => {
+ const user = userEvent.setup()
+ forgeCreateIssue.mockResolvedValue(created())
+ mount([], {
+ expected: {
+ expectedServerHost: "github.com",
+ expectedOwnerRepo: "me/app",
+ },
+ })
+
+ await user.type(screen.getByLabelText("Title"), "Login times out")
+ await user.click(screen.getByRole("button", { name: "Create issue" }))
+
+ await waitFor(() =>
+ expect(forgeCreateIssue).toHaveBeenCalledWith(
+ 7,
+ expect.objectContaining({ title: "Login times out" }),
+ { expectedServerHost: "github.com", expectedOwnerRepo: "me/app" }
+ )
+ )
+ })
+
+ it("tells the page to re-resolve when the folder has moved on", async () => {
+ const user = userEvent.setup()
+ // What the backend answers with when the coordinates no longer match the
+ // folder's remote (`WRITE_MISMATCH_I18N_KEY`).
+ forgeCreateIssue.mockRejectedValue({
+ code: "configuration_invalid",
+ message: "this panel was showing github.com/me/app",
+ i18n_key: "Forge.writeMismatch",
+ i18n_params: {
+ expected: "github.com/me/app",
+ actual: "github.com/acme/app",
+ },
+ })
+ const { onStaleRepository, onCreated } = mount([], {
+ expected: {
+ expectedServerHost: "github.com",
+ expectedOwnerRepo: "me/app",
+ },
+ })
+
+ await user.type(screen.getByLabelText("Title"), "Login times out")
+ await user.click(screen.getByRole("button", { name: "Create issue" }))
+
+ await waitFor(() => expect(onStaleRepository).toHaveBeenCalled())
+ // Nothing was filed, and the dialog does not pretend otherwise.
+ expect(onCreated).not.toHaveBeenCalled()
+ })
})
/**
diff --git a/src/components/forge/forge-new-issue-dialog.tsx b/src/components/forge/forge-new-issue-dialog.tsx
index 6c0248f0ca..6dc85bf2a5 100644
--- a/src/components/forge/forge-new-issue-dialog.tsx
+++ b/src/components/forge/forge-new-issue-dialog.tsx
@@ -3,6 +3,7 @@
import { useCallback, useState } from "react"
import { useTranslations } from "next-intl"
import { Plus } from "lucide-react"
+import { toast } from "sonner"
import { ForgeLabelChip } from "@/components/forge/forge-issue-row"
import { Button } from "@/components/ui/button"
import {
@@ -19,11 +20,15 @@ import { Textarea } from "@/components/ui/textarea"
import { useImeGuard } from "@/hooks/use-ime-guard"
import { forgeCreateIssue } from "@/lib/api"
import {
+ isForgeWriteMismatch,
type AppErrorTranslator,
toLocalizedErrorMessage,
} from "@/lib/app-error"
import { cn } from "@/lib/utils"
-import type { ForgeIssueRow, ForgeLabel } from "@/lib/types"
+import type { ForgeExpectedRepo, ForgeIssueRow, ForgeLabel } from "@/lib/types"
+
+/** Stands in for a caller that wired no re-resolve (a preview, a fixture). */
+const NO_OP = () => {}
/** Mirrors `MAX_TITLE_CHARS` in src-tauri/src/forge/mod.rs. Enforced here as
* well as there so the counter and the button agree with what the forge will
@@ -52,6 +57,8 @@ export function ForgeNewIssueDialog({
folderId,
repo,
labelOptions,
+ expected = null,
+ onStaleRepository = NO_OP,
onOpenChange,
onCreated,
}: {
@@ -59,6 +66,13 @@ export function ForgeNewIssueDialog({
folderId: number
/** `owner/repo`, for the description — the backend derives its own. */
repo: string
+ /** The repository this dialog was opened over, as the pair a write carries
+ * (see `ForgeExpectedRepo`). `null` sends none, which is the old
+ * behaviour. */
+ expected?: ForgeExpectedRepo | null
+ /** The write was refused because the folder has moved to another
+ * repository; the page re-resolves, which closes this dialog. */
+ onStaleRepository?: () => void
/** The repository's label vocabulary, already fetched by the page. Empty
* when it has none (or the read failed), in which case no label control is
* offered at all — one that can only show an empty list is worse than none. */
@@ -97,21 +111,45 @@ export function ForgeNewIssueDialog({
setCreating(true)
setFailure(null)
try {
- const row = await forgeCreateIssue(folderId, {
- title: trimmedTitle,
- body: body.trim() === "" ? null : body.trim(),
- labels,
- })
+ const row = await forgeCreateIssue(
+ folderId,
+ {
+ title: trimmedTitle,
+ body: body.trim() === "" ? null : body.trim(),
+ labels,
+ },
+ expected
+ )
// Only once it exists: clearing before the answer would lose what
// somebody wrote to a network failure they cannot retry from.
reset()
onCreated(row)
} catch (error) {
+ if (isForgeWriteMismatch(error)) {
+ // The page's re-resolve closes this dialog, so the inline strip would
+ // be torn down before it could be read — a toast outlives it.
+ toast.error(
+ toLocalizedErrorMessage(error, tRoot as unknown as AppErrorTranslator)
+ )
+ onStaleRepository()
+ return
+ }
setFailure({ error })
} finally {
setCreating(false)
}
- }, [body, canCreate, folderId, labels, onCreated, reset, trimmedTitle])
+ }, [
+ body,
+ canCreate,
+ expected,
+ folderId,
+ labels,
+ onCreated,
+ onStaleRepository,
+ reset,
+ tRoot,
+ trimmedTitle,
+ ])
return (
({
// each of them.
forgeSettingsGet: vi.fn(),
forgeSettingsSet: vi.fn(),
+ // The panel's remote selections — read once on mount, written by the picker.
+ // Its own store, so the settings mocks above never answer for it.
+ forgeRemoteGet: vi.fn(),
+ forgeRemoteSet: vi.fn(),
+ // Called DURING RENDER to build the pair every write carries, so this mock
+ // has to answer like the real helper rather than like a spy.
+ forgeExpectedRepo: (
+ remote: { server_host: string; owner_repo: string } | null
+ ) =>
+ remote === null
+ ? null
+ : {
+ expectedServerHost: remote.server_host,
+ expectedOwnerRepo: remote.owner_repo,
+ },
+ gitListRemotes: vi.fn(),
}))
vi.mock("@/lib/platform", () => ({
subscribe: vi.fn().mockResolvedValue(() => {}),
@@ -109,9 +126,13 @@ import {
forgeCreateIssue,
forgeListComments,
forgeListLabels,
+ forgeRemoteGet,
+ forgeRemoteSet,
forgeSetItemState,
forgeSettingsGet,
+ forgeSettingsSet,
forgeTabCount,
+ gitListRemotes,
workTaskLookupBySource,
} from "@/lib/api"
@@ -223,6 +244,230 @@ beforeEach(() => {
global: { writeback_default: true, scenario_prompts: {} },
folders: {},
})
+ // Nothing picked in any folder: the panel reads the default remote.
+ vi.mocked(forgeRemoteGet).mockResolvedValue({ folders: {} })
+ vi.mocked(gitListRemotes).mockResolvedValue([])
+})
+
+describe("ForgePage remote picker", () => {
+ function mountWithRemotes() {
+ useAppWorkspaceStore.setState({
+ folders: [
+ {
+ id: 1,
+ name: "codeg",
+ path: "/repo",
+ parent_id: null,
+ kind: "regular",
+ },
+ ] as never,
+ })
+ vi.mocked(gitListRemotes).mockResolvedValue([
+ { name: "origin", url: "https://github.com/me/codeg.git" },
+ { name: "upstream", url: "https://github.com/xintaofei/codeg.git" },
+ ])
+ vi.mocked(forgeListIssues).mockResolvedValue(listOf([]))
+ mount()
+ }
+
+ it("lists the folder's remotes and saves the picked one in its own store", async () => {
+ mountWithRemotes()
+ vi.mocked(forgeRemoteSet).mockResolvedValue({
+ folders: { "1": "upstream" },
+ })
+
+ await userEvent.click(
+ await screen.findByRole("combobox", { name: "Remote" })
+ )
+ await userEvent.click(
+ await screen.findByRole("option", { name: "upstream" })
+ )
+
+ // Its OWN command. A picker click must not land in the panel-settings blob:
+ // that is what used to detach the folder from the global row, and what let
+ // a later "use global defaults" save destroy the choice.
+ await waitFor(() =>
+ expect(vi.mocked(forgeRemoteSet)).toHaveBeenCalledWith(1, "upstream")
+ )
+ expect(vi.mocked(forgeSettingsSet)).not.toHaveBeenCalled()
+ // And the page re-reads the repository it is now pointed at.
+ await waitFor(() =>
+ expect(vi.mocked(folderForgeRemote).mock.calls.length).toBeGreaterThan(1)
+ )
+ })
+
+ it("offers the default explicitly and clears the choice with it", async () => {
+ // The folder is on `upstream`. Nothing in the settings dialog edits the
+ // selection, so this item is the only way back to `origin` — and it has to
+ // CLEAR the entry rather than save the name, or the folder would look like
+ // it had chosen `origin` rather than gone back to the default.
+ vi.mocked(forgeRemoteGet).mockResolvedValue({
+ folders: { "1": "upstream" },
+ })
+ mountWithRemotes()
+ vi.mocked(forgeRemoteSet).mockResolvedValue({ folders: {} })
+
+ await userEvent.click(
+ await screen.findByRole("combobox", { name: "Remote" })
+ )
+ await userEvent.click(
+ await screen.findByRole("option", { name: "Default (origin)" })
+ )
+
+ await waitFor(() =>
+ expect(vi.mocked(forgeRemoteSet)).toHaveBeenCalledWith(1, null)
+ )
+ })
+})
+
+/**
+ * What a remote switch must TEAR DOWN.
+ *
+ * The picker changes which repository every request is aimed at, so every piece
+ * of state that was only true of the previous one has to go with it. Left
+ * behind, each of these reads as a fact about the NEW repository: a page
+ * number, a label vocabulary, and the previous repository's rows — the last one
+ * painted by a response that was already in flight when the switch happened.
+ */
+describe("ForgePage remote switch", () => {
+ const UPSTREAM: ForgeRemote = {
+ remote_name: "upstream",
+ server_host: "github.com",
+ owner_repo: "acme/codeg-parent",
+ remote_url: "https://github.com/acme/codeg-parent.git",
+ provider: "github",
+ supported: true,
+ }
+
+ function mountWithPicker() {
+ useAppWorkspaceStore.setState({
+ folders: [
+ {
+ id: 1,
+ name: "codeg",
+ path: "/repo",
+ parent_id: null,
+ kind: "regular",
+ },
+ ] as never,
+ })
+ vi.mocked(gitListRemotes).mockResolvedValue([
+ { name: "origin", url: "https://github.com/me/codeg.git" },
+ { name: "upstream", url: "https://github.com/acme/codeg-parent.git" },
+ ])
+ // The first resolution is the fork; every one after the pick is the
+ // parent, which is what makes the switch observable from the outside.
+ vi.mocked(folderForgeRemote)
+ .mockResolvedValueOnce(REMOTE)
+ .mockResolvedValue(UPSTREAM)
+ mount()
+ }
+
+ async function pickUpstream(user: ReturnType) {
+ await user.click(await screen.findByRole("combobox", { name: "Remote" }))
+ await user.click(await screen.findByRole("option", { name: "upstream" }))
+ }
+
+ it("returns to page 1 instead of asking the new repository for the old page", async () => {
+ const user = userEvent.setup()
+ vi.mocked(forgeListIssues).mockImplementation(async (_folderId, req) =>
+ listOf([issue(req.page ?? 1, `row on page ${req.page}`)], {
+ page: req.page ?? 1,
+ total_count: 57,
+ has_next: (req.page ?? 1) < 3,
+ })
+ )
+ mountWithPicker()
+ await screen.findByText("row on page 1")
+ await user.click(screen.getByRole("button", { name: "Go to page 3" }))
+ await screen.findByText("row on page 3")
+
+ const before = sentQueries().length
+ await pickUpstream(user)
+
+ // The FIRST request aimed at the new repository is the one that matters:
+ // page 3 of one repository is a different slice of another, and a header
+ // saying "page 3" over rows the reader never asked for is the same bug the
+ // page-size control already avoids. Asserted over EVERY request since the
+ // pick rather than only the last one: a fetch for the old page can be
+ // overtaken by the refetch that corrects it, and the wasted round trip —
+ // and the rows it briefly paints — would go unnoticed.
+ await waitFor(() => {
+ expect(vi.mocked(folderForgeRemote).mock.calls.length).toBeGreaterThan(1)
+ })
+ expect(lastQuery().page ?? 1).toBe(1)
+ expect(
+ sentQueries()
+ .slice(before)
+ .map((q) => q.page ?? 1)
+ ).not.toContain(3)
+ })
+
+ it("clears the label filter, which belongs to the old repository", async () => {
+ const user = userEvent.setup()
+ // The parent's vocabulary does NOT include `bug`. Left in place, the filter
+ // would come back empty and read as "this repository has no issues".
+ vi.mocked(forgeListIssues).mockResolvedValue(listOf([issue(1, "a row")]))
+ vi.mocked(forgeListLabels)
+ .mockResolvedValueOnce({
+ labels: [{ name: "bug", color: "#d73a4a" }],
+ truncated: false,
+ })
+ .mockResolvedValue({
+ labels: [{ name: "feature", color: "#0e8a16" }],
+ truncated: false,
+ })
+ mountWithPicker()
+ await screen.findByText("a row")
+
+ await user.click(screen.getByRole("button", { name: "Labels" }))
+ await user.click(await screen.findByRole("option", { name: "bug" }))
+ // Closing the popover, so the click that follows reaches the picker.
+ await user.keyboard("{Escape}")
+ await waitFor(() => expect(lastQuery().labels).toEqual(["bug"]))
+
+ await pickUpstream(user)
+
+ await waitFor(() => {
+ expect(vi.mocked(folderForgeRemote).mock.calls.length).toBeGreaterThan(1)
+ })
+ expect(lastQuery().labels ?? []).toEqual([])
+ // And the label list on offer is the new repository's, not a filter built
+ // from names that only existed in the old one.
+ expect(vi.mocked(forgeListLabels).mock.calls.length).toBeGreaterThan(1)
+ })
+
+ it("never paints the previous repository's rows over the new one", async () => {
+ const user = userEvent.setup()
+ let releaseOld: ((value: ForgeIssueList) => void) | null = null
+ let calls = 0
+ vi.mocked(forgeListIssues).mockImplementation(async () => {
+ calls += 1
+ // The first page is held in flight and released by the test AFTER the
+ // switch; the second (the parent's) resolves immediately.
+ if (calls === 1) {
+ return new Promise((resolve) => {
+ releaseOld = resolve
+ })
+ }
+ return listOf([issue(99, "row from the parent")])
+ })
+ mountWithPicker()
+ await screen.findByRole("combobox", { name: "Remote" })
+
+ await pickUpstream(user)
+ // The parent's rows land first…
+ await screen.findByText("row from the parent")
+ // …and only then does the response that was already in flight arrive,
+ // carrying a row from the fork the panel has left.
+ await act(async () => {
+ releaseOld?.(listOf([issue(1, "row from the fork")]))
+ await Promise.resolve()
+ })
+
+ expect(screen.queryByText("row from the fork")).toBeNull()
+ expect(screen.getByText("row from the parent")).toBeInTheDocument()
+ })
})
describe("ForgePage list failures", () => {
@@ -376,6 +621,7 @@ describe("ForgePage list failures", () => {
*/
describe("ForgePage sort control", () => {
const GITEA: ForgeRemote = {
+ remote_name: "origin",
server_host: "git.corp.example",
owner_repo: "acme/app",
remote_url: "https://git.corp.example/acme/app.git",
@@ -418,6 +664,7 @@ describe("ForgePage sort control", () => {
*/
describe("ForgePage on a host that is neither forge", () => {
const UNSUPPORTED: ForgeRemote = {
+ remote_name: "origin",
server_host: "gitee.com",
owner_repo: "someone/thing",
remote_url: "https://gitee.com/someone/thing.git",
@@ -1715,9 +1962,7 @@ describe("ForgePage writes", () => {
vi.mocked(folderForgeRemote).mockResolvedValue(null)
vi.mocked(forgeListIssues).mockResolvedValue(listOf([]))
mount()
- await screen.findByText(
- "This folder has no recognizable forge remote (origin)"
- )
+ await screen.findByText("This folder has no recognizable forge remote")
// Nowhere for the issue to go — the backend would refuse it, and a button
// that can only fail is worse than no button.
expect(
@@ -1742,7 +1987,13 @@ describe("ForgePage writes", () => {
await waitFor(() =>
expect(forgeCreateIssue).toHaveBeenCalledWith(
1,
- expect.objectContaining({ title: "Login times out" })
+ expect.objectContaining({ title: "Login times out" }),
+ // The repository the page is SHOWING, handed to the dialog so the
+ // write is refused rather than redirected if the folder has moved.
+ {
+ expectedServerHost: "github.com",
+ expectedOwnerRepo: "xintaofei/codeg",
+ }
)
)
// Straight into the panel on what was just filed: the number and the link
@@ -1759,6 +2010,39 @@ describe("ForgePage writes", () => {
expect(vi.mocked(forgeListIssues).mock.calls.length).toBe(before)
})
+ it("re-resolves the repository when a write says the folder has moved on", async () => {
+ const user = userEvent.setup()
+ vi.mocked(forgeListIssues).mockResolvedValue(listOf([]))
+ // What the backend answers with when the coordinates a write carried no
+ // longer match the folder's remote (`WRITE_MISMATCH_I18N_KEY`): the write
+ // was refused, and the page's job is to stop showing a repository the
+ // folder has left.
+ vi.mocked(forgeCreateIssue).mockRejectedValue({
+ code: "configuration_invalid",
+ message: "this panel was showing github.com/xintaofei/codeg",
+ i18n_key: "Forge.writeMismatch",
+ i18n_params: {
+ expected: "github.com/xintaofei/codeg",
+ actual: "github.com/acme/other",
+ },
+ })
+ mount()
+ const before = vi.mocked(folderForgeRemote).mock.calls.length
+
+ await user.click(await screen.findByRole("button", { name: "New issue" }))
+ await user.type(screen.getByLabelText("Title"), "Login times out")
+ await user.click(screen.getByRole("button", { name: "Create issue" }))
+
+ // Re-resolved — which is what tears the stale rows, panel and dialogs
+ // down with it. Merely toasting would leave every later action aimed at
+ // the same repository the refusal was about.
+ await waitFor(() =>
+ expect(vi.mocked(folderForgeRemote).mock.calls.length).toBeGreaterThan(
+ before
+ )
+ )
+ })
+
it("counts the issue it filed onto the tab badge", async () => {
const user = userEvent.setup()
vi.mocked(forgeListIssues).mockResolvedValue(
diff --git a/src/components/forge/forge-page.tsx b/src/components/forge/forge-page.tsx
index 34847ecdd3..1ad131fd70 100644
--- a/src/components/forge/forge-page.tsx
+++ b/src/components/forge/forge-page.tsx
@@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react"
import { useTranslations } from "next-intl"
+import { toast } from "sonner"
import {
Check,
ExternalLink,
@@ -73,15 +74,20 @@ import { ForgeStartDialog } from "@/components/forge/forge-start-dialog"
import { useIsMobile } from "@/hooks/use-mobile"
import {
folderForgeRemote,
+ forgeExpectedRepo,
forgeListIssues,
forgeListLabels,
+ forgeRemoteGet,
+ forgeRemoteSet,
forgeSettingsGet,
forgeTabCount,
+ gitListRemotes,
openSettingsWindow,
workTaskLookupBySource,
} from "@/lib/api"
import {
extractAppCommandError,
+ toErrorMessage,
toLocalizedErrorMessage,
type AppErrorTranslator,
} from "@/lib/app-error"
@@ -104,10 +110,12 @@ import type {
ForgeLabel,
ForgeProviderId,
ForgeRemote,
+ ForgeRemoteStore,
ForgeSort,
ForgeTab,
ForgeSettingsStore,
ForgeTaskLink,
+ GitRemote,
} from "@/lib/types"
import { useAppWorkspaceStore } from "@/stores/app-workspace-store"
import { useForgeRefreshStore } from "@/stores/forge-refresh-store"
@@ -127,6 +135,15 @@ const FOLDER_STORAGE_KEY = "forge:folderId"
* matches, so every symbol in here read as one that does not exist. */
const LABEL_SCOPE_SEP = String.fromCharCode(0)
+/** The remote the panel reads when a folder has no selection — mirrors
+ * `DEFAULT_FORGE_REMOTE` in `src-tauri/src/commands/forge.rs`. */
+const DEFAULT_REMOTE = "origin"
+
+/** The picker's "no selection" item. MUST NOT be a possible git remote name:
+ * a space cannot appear in a refname, which is what a remote name is — so this
+ * can never collide with a real remote the folder happens to have. */
+const REMOTE_DEFAULT_ITEM = " default"
+
/** Must mirror `NO_ACCOUNT_I18N_KEY` in src-tauri/src/forge/mod.rs. The key —
* not the error `code` — is the discriminator: `configuration_missing` is a
* generic code that other failures share, and offering "add an account" for
@@ -561,9 +578,20 @@ export function ForgePage() {
}
return projectFolders[0]?.id ?? null
}, [folderId, projectFolders])
+ const effectiveFolderPath = useMemo(
+ () => projectFolders.find((f) => f.id === effectiveFolderId)?.path ?? null,
+ [projectFolders, effectiveFolderId]
+ )
const [remote, setRemote] = useState(null)
const [remoteLoading, setRemoteLoading] = useState(false)
+ /** Every remote in the selected folder — the picker's options. Loaded with
+ * the folder, not with the resolved remote, so a selection that no longer
+ * resolves still lets the user move off it. */
+ const [remotes, setRemotes] = useState([])
+ /** Bumped after the selection is saved. The resolution effect depends on it,
+ * so the page re-reads the repository it is now pointed at. */
+ const [remoteVersion, setRemoteVersion] = useState(0)
/** Bumped when the backend reports it had this host's forge wrong. It is a
* dependency of the remote lookup, so bumping it re-derives `provider` —
* which is what makes the correction visible in the tab wording too, not
@@ -610,10 +638,21 @@ export function ForgePage() {
* than as a reason to wait. Held as the whole store rather than as one
* folder's resolved values so switching folders costs no round trip. */
const [settings, setSettings] = useState(null)
+ /** Which git remote each folder reads — the picker's own store, held whole
+ * like the settings above so switching folders costs no round trip. Read
+ * and written ONLY by the picker: the settings dialog cannot reach it, and
+ * vice versa. `null` means "not loaded yet, or the read failed", which the
+ * picker treats as the default rather than as a reason to wait. */
+ const [remoteStore, setRemoteStore] = useState(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [labelOptions, setLabelOptions] = useState([])
const [labelsTruncated, setLabelsTruncated] = useState(false)
const reqRef = useRef(0)
+ /** The repository the generation above belongs to, and a counter kept in
+ * step with it. `reqRef` is the guard for EVERY request aimed at the current
+ * repository, so it has to be claimed when the repository changes and there
+ * is no request of ours to claim it — see the render-phase bump below. */
+ const repoRef = useRef(null)
/** Rows taken from a write, keyed by item — what `reconcile` writes back
* over a list response that went out before the write. A ref, not state: it
* has to be readable by a fetch already in flight, and changing it must
@@ -678,7 +717,62 @@ export function ForgePage() {
return () => {
cancelled = true
}
- }, [effectiveFolderId, forgeCorrection])
+ }, [effectiveFolderId, forgeCorrection, remoteVersion])
+
+ // The picker's options come straight from git, so the list is complete even
+ // when none of them is recognizable as a forge.
+ useEffect(() => {
+ if (effectiveFolderPath == null) {
+ setRemotes([])
+ return
+ }
+ let cancelled = false
+ gitListRemotes(effectiveFolderPath)
+ .then((list) => {
+ if (!cancelled) setRemotes(list)
+ })
+ .catch(() => {
+ if (!cancelled) setRemotes([])
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [effectiveFolderPath])
+
+ /**
+ * A write came back REFUSED because the folder now reads another repository.
+ *
+ * Re-resolve: the rows, the counts, the panel and the trigger dialog all
+ * belong to a repository this page is no longer showing, and the resolution
+ * effect's teardown is what puts them away. The message itself is shown by
+ * whoever caught the refusal — the panel that raised it is about to unmount.
+ */
+ const handleStaleRepository = useCallback(() => {
+ setRemoteVersion((v) => v + 1)
+ }, [])
+
+ // Switching the remote saves the choice on the folder, then re-runs the
+ // resolution above: the rows on screen belong to the repository the backend
+ // would read next, so the old ones must not survive the switch.
+ //
+ // Its OWN store, not the panel settings: that blob is rewritten wholesale by
+ // the settings dialog, and the picker writing into it is what used to detach
+ // the folder from the global row — and what let a later "use global
+ // defaults" save destroy a choice the user had just made. `null` is the
+ // picker's "default" item: no selection, so the folder reads `origin`.
+ const handlePickRemote = useCallback(
+ async (name: string | null) => {
+ if (effectiveFolderId == null) return
+ try {
+ const next = await forgeRemoteSet(effectiveFolderId, name)
+ setRemoteStore(next)
+ setRemoteVersion((v) => v + 1)
+ } catch (e) {
+ toast.error(toErrorMessage(e))
+ }
+ },
+ [effectiveFolderId]
+ )
/**
* The remote only when codeg can actually read it.
@@ -696,11 +790,78 @@ export function ForgePage() {
*/
const readable = remote?.supported ? remote : null
- /** Which list the rows belong to — see [`LoadedList`]. */
- const listScope = `${effectiveFolderId}:${tab}`
+ /**
+ * WHICH repository everything below is about.
+ *
+ * The folder alone is not the answer, and the picker is why: one folder can
+ * be pointed at several repositories in turn, so the page number, the label
+ * vocabulary and the counted rows on screen are facts about a (folder,
+ * remote) PAIR. Keyed on the folder alone they outlive the switch, and each
+ * one then reads as a fact about the repository now on screen.
+ *
+ * A NAME rather than the object, so it is a stable fetch dependency: the
+ * resolution hands back a fresh object every time, and comparing those would
+ * re-run every fetch on each re-resolution of the SAME remote.
+ */
+ const repoKey =
+ readable == null ? "none" : `${readable.server_host}/${readable.owner_repo}`
+
+ /**
+ * What every WRITE carries: the repository this panel is SHOWING.
+ *
+ * Built from the RESOLVED remote rather than from `repoKey`, though the two
+ * name the same repository — the backend compares this pair against what it
+ * derives, and splitting the key back apart would put a parser between two
+ * spellings of one fact.
+ */
+ const expectedRepo = useMemo(() => forgeExpectedRepo(readable), [readable])
+
+ /**
+ * The switch claims a generation of its own.
+ *
+ * `reqRef` is what decides whether an answer is still wanted, and today it
+ * happens to be safe without this: the refetch for the new repository runs on
+ * the commit that resolves it and takes the next number, so an answer still in
+ * the air loses. That safety is a consequence of the refetch happening at all,
+ * though — not of anything the switch does — and the two states where it does
+ * NOT happen are exactly the ones where a stale answer can still be believed:
+ * a remote that resolves to nothing readable (no refetch is fired, so no
+ * number is claimed), and the frame between the teardown and the resolution.
+ * Taking the number here states the rule directly — a repository change
+ * invalidates everything aimed at the last one — and needs no fetch to be
+ * fired for it to hold.
+ *
+ * During RENDER, so it is claimed in the same commit that resolves the new
+ * remote and before any effect can run. Absorbed, because this runs on every
+ * render and writing state unconditionally would loop.
+ */
+ if (repoRef.current !== repoKey) {
+ repoRef.current = repoKey
+ reqRef.current += 1
+ }
+
+ /** The folder's selected remote name even when it does not resolve — the
+ * picker must show what the folder is SET to, not only what loaded. Read
+ * from the selection store rather than from the resolution, which reports
+ * the default as a name whenever nothing was chosen. */
+ const selectedRemoteName = useMemo(
+ () =>
+ effectiveFolderId == null
+ ? null
+ : (remoteStore?.folders[String(effectiveFolderId)] ?? null),
+ [remoteStore, effectiveFolderId]
+ )
+
+ /** Which list the rows belong to — see [`LoadedList`]. Carries the remote,
+ * not just the folder: switching the picker swaps the repository under the
+ * same folder id, and a page read from one forge must never be shown as the
+ * other's. */
+ const listScope = `${effectiveFolderId}:${repoKey}:${tab}`
/** Which RESULT SET the badges count — see [`TabCounts`]. No tab, no page,
- * no order: none of the three can change either number. */
- const countsScope = `${effectiveFolderId}:${stateFilter}:${assignedMe}:${labelFilter.join(LABEL_SCOPE_SEP)}:${search}`
+ * no order: none of the three can change either number. Remote included for
+ * the same reason as `listScope`: the two repositories have unrelated
+ * totals. */
+ const countsScope = `${effectiveFolderId}:${repoKey}:${stateFilter}:${assignedMe}:${labelFilter.join(LABEL_SCOPE_SEP)}:${search}`
/**
* Everything that decides whether a row belongs on the page being shown: the
* folder and tab, the filter set, and the order and page number that place it
@@ -945,6 +1106,11 @@ export function ForgePage() {
// straight back. A failure is silent on purpose — the trigger dialog falls
// back to the built-in defaults, and a toast about preferences nobody asked
// for yet would be noise over a page that works.
+ //
+ // The remote selections come along for the same ride and the same reason:
+ // they are the picker's own store, read once, and a failure leaves the
+ // picker on the default rather than blocking a page that reads repositories
+ // perfectly well.
useEffect(() => {
let cancelled = false
forgeSettingsGet()
@@ -952,6 +1118,11 @@ export function ForgePage() {
if (!cancelled) setSettings(s)
})
.catch(() => {})
+ forgeRemoteGet()
+ .then((s) => {
+ if (!cancelled) setRemoteStore(s)
+ })
+ .catch(() => {})
const open = () => setSettingsOpen(true)
window.addEventListener(OPEN_FORGE_SETTINGS_EVENT, open)
return () => {
@@ -974,18 +1145,35 @@ export function ForgePage() {
// A different repository has a different label vocabulary, so a selection
// made against the old one would filter by labels that may not exist here.
+ // The remote is part of "a different repository" — one folder can be pointed
+ // at a fork and then its parent — so the key is the pair, not the folder.
// Derived during render rather than in an effect: this has to catch the
// FALLBACK path too (the stored folder disappearing from the workspace), and
// an effect would spend an extra render — and an extra request — doing it.
- const [labelledFolder, setLabelledFolder] = useState(effectiveFolderId)
- if (labelledFolder !== effectiveFolderId) {
- setLabelledFolder(effectiveFolderId)
+ const [labelledScope, setLabelledScope] = useState(
+ `${effectiveFolderId}:${repoKey}`
+ )
+ if (labelledScope !== `${effectiveFolderId}:${repoKey}`) {
+ setLabelledScope(`${effectiveFolderId}:${repoKey}`)
if (labelFilter.length > 0) {
setLabelFilter([])
setPage(1)
}
}
+ // The page number belongs to the repository as much as the label selection
+ // does: page 3 of a fork is a different slice of its parent, and asking the
+ // parent for it lands the reader on rows nobody chose. Switched the same way
+ // — during render, so the reset is committed in the same pass that the new
+ // repository resolves, BEFORE any effect can fetch the old page against it.
+ const [pagedScope, setPagedScope] = useState(
+ `${effectiveFolderId}:${repoKey}`
+ )
+ if (pagedScope !== `${effectiveFolderId}:${repoKey}`) {
+ setPagedScope(`${effectiveFolderId}:${repoKey}`)
+ setPage(1)
+ }
+
// The repository's label vocabulary — once per repository, not per page:
// labels barely change, and on GitHub this runs on the core quota rather than
// search's much smaller one. Best-effort: a repository whose labels cannot be
@@ -1408,6 +1596,9 @@ export function ForgePage() {
folderId={effectiveFolderId}
onPickFolder={pickFolder}
remote={remote}
+ remotes={remotes}
+ remoteName={selectedRemoteName}
+ onPickRemote={handlePickRemote}
/>
{/* Only once a repository is resolved: without one there is nowhere
@@ -1687,6 +1878,14 @@ export function ForgePage() {
// list was fetched with, so a folder switch (which closes the panel —
// see the reset effect above) cannot leave the two disagreeing.
folderId={effectiveFolderId}
+ // Which repository that folder is pointed AT. The panel's repository
+ // facts — the account a comment is signed as, the merge methods the
+ // forge permits — are asked for by folder, so the folder alone cannot
+ // tell the panel whether its answer is still about the repository on
+ // screen. Same spelling as the scopes above, from the same value.
+ repo={repoKey}
+ expected={expectedRepo}
+ onStaleRepository={handleStaleRepository}
onOpenChange={(open) => {
if (!open) setDetailRow(null)
}}
@@ -1706,6 +1905,8 @@ export function ForgePage() {
// repository — one read serves both, and the dialog must not wait on
// a round trip to draw.
labelOptions={labelOptions}
+ expected={expectedRepo}
+ onStaleRepository={handleStaleRepository}
onOpenChange={setNewIssueOpen}
onCreated={(created) => {
setNewIssueOpen(false)
@@ -1847,6 +2048,9 @@ function RepoBar({
folderId,
onPickFolder,
remote,
+ remotes,
+ remoteName,
+ onPickRemote,
}: {
folders: readonly FolderSelectOption[]
folderId: number | null
@@ -1854,6 +2058,14 @@ function RepoBar({
/** `null` until the folder resolves, or for a folder with no forge remote —
* the picker still has to be usable, so only the right half goes away. */
remote: ForgeRemote | null
+ /** Every remote in the folder — the picker's options. */
+ remotes: GitRemote[]
+ /** The folder's SAVED selection, or `null` when it is on the default. NOT
+ * the resolved name: a folder with nothing saved resolves to `origin`, and
+ * painting that as a picked remote would hide the fact that the folder is
+ * following the default — and the item that clears a choice. */
+ remoteName: string | null
+ onPickRemote: (name: string | null) => void
}) {
const t = useTranslations("Forge")
@@ -1869,6 +2081,37 @@ function RepoBar({
title={t("pickFolder")}
variant="ghost"
/>
+ {remotes.length > 0 ? (
+
+ onPickRemote(value === REMOTE_DEFAULT_ITEM ? null : value)
+ }
+ >
+
+
+
+
+ {/* The way OFF a choice. Without it a folder that picked a remote
+ could never go back to the default: this store is not editable
+ from the settings dialog, and picking `origin` would save a
+ choice rather than clear one. */}
+
+ {t("remoteDefault", { name: DEFAULT_REMOTE })}
+
+ {remotes.map((r) => (
+
+ {r.name}
+
+ ))}
+
+
+ ) : null}
{remote != null ? (
<>
diff --git a/src/components/forge/forge-settings-dialog.test.tsx b/src/components/forge/forge-settings-dialog.test.tsx
index ea98265365..9c476e4f02 100644
--- a/src/components/forge/forge-settings-dialog.test.tsx
+++ b/src/components/forge/forge-settings-dialog.test.tsx
@@ -156,6 +156,25 @@ describe("ForgeSettingsDialog global scope", () => {
expect(onSaved).toHaveBeenCalledWith({ global: settings, folders: {} })
})
+ it("sends nothing about the remote, which the panel's picker owns", async () => {
+ const user = userEvent.setup()
+ await mountLoaded()
+
+ await user.click(screen.getByRole("button", { name: "Save" }))
+
+ await waitFor(() => expect(forgeSettingsSet).toHaveBeenCalled())
+ // The selection lives in its own store and is written by the picker alone.
+ // A settings save must not carry a value a later read would treat as
+ // chosen — and must not be able to clear one either: this blob is dropped
+ // wholesale by "use the global defaults".
+ expect(Object.keys(lastSave().settings).sort()).toEqual([
+ "default_issue_scenario",
+ "default_pr_scenario",
+ "scenario_prompts",
+ "writeback_default",
+ ])
+ })
+
it("keeps each scenario's instruction under its own segment, and marks the ones in use", async () => {
const user = userEvent.setup()
await mountLoaded()
diff --git a/src/components/forge/forge-start-dialog.test.tsx b/src/components/forge/forge-start-dialog.test.tsx
index 1254707605..4ea5a785cb 100644
--- a/src/components/forge/forge-start-dialog.test.tsx
+++ b/src/components/forge/forge-start-dialog.test.tsx
@@ -41,6 +41,7 @@ vi.mock("@/contexts/workbench-route-context", () => ({
}))
const GITHUB: ForgeRemote = {
+ remote_name: "origin",
server_host: "github.com",
owner_repo: "o/r",
remote_url: "https://github.com/o/r.git",
@@ -48,6 +49,7 @@ const GITHUB: ForgeRemote = {
supported: true,
}
const GITLAB: ForgeRemote = {
+ remote_name: "origin",
server_host: "gitlab.com",
owner_repo: "group/sub/app",
remote_url: "https://gitlab.com/group/sub/app.git",
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index 08361d289d..2449587e2e 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -5617,8 +5617,10 @@
},
"Forge": {
"title": "لوحة المستودع",
+ "remote": "remote",
+ "remoteDefault": "الافتراضي ({name})",
"pickFolder": "اختر مجلد مشروع",
- "noRemote": "لا يحتوي هذا المجلد على remote معروف (origin)",
+ "noRemote": "لا يحتوي هذا المجلد على remote معروف",
"errors": {
"noAccount": "لا يوجد حساب {provider} مُهيّأ للمضيف {host}. أضف حسابًا من الإعدادات ← التحكم بالإصدارات لتحميل هذا المستودع.",
"unsupportedHost": "لوحة المستودع تدعم GitHub و GitLab و Gitea فقط، و{host} لا يُعرَف كأي منها. إذا كان مثيلًا مستضافًا ذاتيًا من GitHub Enterprise أو GitLab أو Gitea أو Forgejo، فأضف له حسابًا من الإعدادات ← التحكم بالإصدارات.",
@@ -5775,6 +5777,7 @@
"previewEmpty": "(بدون وصف)",
"duplicateBody": "توجد مهمة نشطة تعالج هذا العنصر بالفعل: {title} ({status}). إنشاء أخرى على أي حال؟",
"folderMismatch": "remote هذا المجلد هو {remote}، وليس مستودع هذه القضية — اختر المجلد المطابق.",
+ "writeMismatch": "كانت هذه اللوحة تعرض {expected}، لكن الطرف البعيد للمجلد أصبح الآن {actual} — تم رفض الكتابة بدلاً من إرسالها إلى المستودع الخاطئ. تم تحديث اللوحة؛ حاول مرة أخرى.",
"cancel": "إلغاء",
"create": "إنشاء مهمة",
"creating": "جارٍ الإنشاء…",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index a4fcf64caa..ecb20f9ca5 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -5617,8 +5617,10 @@
},
"Forge": {
"title": "Repository-Panel",
+ "remote": "Remote",
+ "remoteDefault": "Standard ({name})",
"pickFolder": "Projektordner wählen",
- "noRemote": "Dieser Ordner hat kein erkennbares Forge-Remote (origin)",
+ "noRemote": "Dieser Ordner hat kein erkennbares Forge-Remote",
"errors": {
"noAccount": "Für {host} ist kein {provider}-Konto konfiguriert. Füge unter Einstellungen → Versionskontrolle eines hinzu, um dieses Repository zu laden.",
"unsupportedHost": "Das Repository-Panel unterstützt nur GitHub, GitLab und Gitea, und {host} wird als keines davon erkannt. Falls es sich um eine selbst gehostete GitHub-Enterprise-, GitLab-, Gitea- oder Forgejo-Instanz handelt, füge unter Einstellungen → Versionskontrolle ein Konto dafür hinzu.",
@@ -5775,6 +5777,7 @@
"previewEmpty": "(keine Beschreibung)",
"duplicateBody": "Eine aktive Aufgabe bearbeitet dieses Element bereits: {title} ({status}). Trotzdem eine weitere erstellen?",
"folderMismatch": "Das Remote dieses Ordners ist {remote}, nicht das Repository dieses Issues — wähle den passenden Ordner.",
+ "writeMismatch": "Dieses Panel zeigte {expected}, aber das Remote des Ordners ist jetzt {actual} — der Schreibvorgang wurde abgelehnt statt an das falsche Repository gesendet. Das Panel wurde neu geladen; bitte erneut versuchen.",
"cancel": "Abbrechen",
"create": "Aufgabe erstellen",
"creating": "Erstelle…",
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 7c84cab256..3043a2b5c3 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -5617,8 +5617,10 @@
},
"Forge": {
"title": "Repository panel",
+ "remote": "Remote",
+ "remoteDefault": "Default ({name})",
"pickFolder": "Pick a project folder",
- "noRemote": "This folder has no recognizable forge remote (origin)",
+ "noRemote": "This folder has no recognizable forge remote",
"errors": {
"noAccount": "No {provider} account is configured for {host}. Add one under Settings → Version Control to load this repository.",
"unsupportedHost": "The repository panel supports GitHub, GitLab and Gitea only, and {host} is not recognized as any of them. If it is a self-hosted GitHub Enterprise, GitLab, Gitea or Forgejo instance, add an account for it under Settings → Version Control.",
@@ -5775,6 +5777,7 @@
"previewEmpty": "(no description)",
"duplicateBody": "An active task already handles this item: {title} ({status}). Create another one anyway?",
"folderMismatch": "This folder's remote is {remote}, not this issue's repository — pick the matching folder.",
+ "writeMismatch": "This panel was showing {expected}, but the folder's remote is now {actual} — the write was refused rather than sent to the wrong repository. The panel has been refreshed; try again.",
"cancel": "Cancel",
"create": "Create task",
"creating": "Creating…",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 7d7af796b7..edae24c644 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -5617,8 +5617,10 @@
},
"Forge": {
"title": "Panel del repositorio",
+ "remote": "Remoto",
+ "remoteDefault": "Predeterminado ({name})",
"pickFolder": "Elige una carpeta de proyecto",
- "noRemote": "Esta carpeta no tiene un remoto reconocible (origin)",
+ "noRemote": "Esta carpeta no tiene un remoto reconocible",
"errors": {
"noAccount": "No hay ninguna cuenta de {provider} configurada para {host}. Añade una en Configuración → Control de versiones para cargar este repositorio.",
"unsupportedHost": "El panel del repositorio solo admite GitHub, GitLab y Gitea, y {host} no se reconoce como ninguno de ellos. Si es una instancia autoalojada de GitHub Enterprise, GitLab, Gitea o Forgejo, añade una cuenta para ella en Configuración → Control de versiones.",
@@ -5775,6 +5777,7 @@
"previewEmpty": "(sin descripción)",
"duplicateBody": "Ya hay una tarea activa para este elemento: {title} ({status}). ¿Crear otra de todos modos?",
"folderMismatch": "El remoto de esta carpeta es {remote}, no el repositorio de este issue: elige la carpeta correcta.",
+ "writeMismatch": "Este panel mostraba {expected}, pero el remoto de la carpeta ahora es {actual}: la escritura se rechazó en lugar de enviarla al repositorio equivocado. El panel se ha actualizado; inténtalo de nuevo.",
"cancel": "Cancelar",
"create": "Crear tarea",
"creating": "Creando…",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 12da2f2c78..74cf50a927 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -5617,8 +5617,10 @@
},
"Forge": {
"title": "Panneau du dépôt",
+ "remote": "Remote",
+ "remoteDefault": "Par défaut ({name})",
"pickFolder": "Choisir un dossier de projet",
- "noRemote": "Ce dossier n'a pas de remote reconnaissable (origin)",
+ "noRemote": "Ce dossier n'a pas de remote reconnaissable",
"errors": {
"noAccount": "Aucun compte {provider} n'est configuré pour {host}. Ajoutez-en un dans Paramètres → Contrôle de version pour charger ce dépôt.",
"unsupportedHost": "Le panneau du dépôt ne prend en charge que GitHub, GitLab et Gitea, et {host} n'est reconnu comme aucun d'entre eux. S'il s'agit d'une instance auto-hébergée de GitHub Enterprise, GitLab, Gitea ou Forgejo, ajoutez-y un compte dans Paramètres → Contrôle de version.",
@@ -5775,6 +5777,7 @@
"previewEmpty": "(aucune description)",
"duplicateBody": "Une tâche active traite déjà cet élément : {title} ({status}). En créer une autre quand même ?",
"folderMismatch": "Le remote de ce dossier est {remote}, pas le dépôt de cette issue — choisissez le bon dossier.",
+ "writeMismatch": "Ce panneau affichait {expected}, mais le dépôt distant du dossier est désormais {actual} — l’écriture a été refusée plutôt qu’envoyée au mauvais dépôt. Le panneau a été actualisé ; réessayez.",
"cancel": "Annuler",
"create": "Créer la tâche",
"creating": "Création…",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index be0fef3a5b..fac3e7c3f8 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -5617,8 +5617,10 @@
},
"Forge": {
"title": "リポジトリパネル",
+ "remote": "リモート",
+ "remoteDefault": "デフォルト({name})",
"pickFolder": "プロジェクトフォルダを選択",
- "noRemote": "このフォルダには認識できるリモート(origin)がありません",
+ "noRemote": "このフォルダには認識できるリモートがありません",
"errors": {
"noAccount": "{host} の {provider} アカウントが設定されていません。「設定 → バージョン管理」で追加するとこのリポジトリを読み込めます。",
"unsupportedHost": "リポジトリパネルは GitHub、GitLab、Gitea にのみ対応しており、{host} はそのいずれとしても認識できません。自己ホスト型の GitHub Enterprise、GitLab、Gitea、Forgejo インスタンスであれば、「設定 → バージョン管理」でアカウントを追加してください。",
@@ -5775,6 +5777,7 @@
"previewEmpty": "(説明なし)",
"duplicateBody": "この項目はすでにアクティブなタスクが対応中です:{title}({status})。それでも作成しますか?",
"folderMismatch": "このフォルダのリモートは {remote} で、この Issue のリポジトリと一致しません。対応するフォルダを選択してください。",
+ "writeMismatch": "このパネルは {expected} を表示していましたが、フォルダーのリモートは現在 {actual} です — 書き込みは誤ったリポジトリに送られる代わりに拒否されました。パネルを再読み込みしました。もう一度お試しください。",
"cancel": "キャンセル",
"create": "タスクを作成",
"creating": "作成中…",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index 43e1b7255d..85c8141c21 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -5617,8 +5617,10 @@
},
"Forge": {
"title": "리포지토리 패널",
+ "remote": "원격",
+ "remoteDefault": "기본값({name})",
"pickFolder": "프로젝트 폴더 선택",
- "noRemote": "이 폴더에는 인식 가능한 원격 저장소(origin)가 없습니다",
+ "noRemote": "이 폴더에는 인식 가능한 원격 저장소가 없습니다",
"errors": {
"noAccount": "{host}에 대한 {provider} 계정이 설정되지 않았습니다. 설정 → 버전 관리에서 추가하면 이 저장소를 불러올 수 있습니다.",
"unsupportedHost": "리포지토리 패널은 GitHub, GitLab, Gitea만 지원하며, {host}는 그중 어느 쪽으로도 인식되지 않습니다. 자체 호스팅 GitHub Enterprise, GitLab, Gitea 또는 Forgejo 인스턴스라면 설정 → 버전 관리에서 계정을 추가하세요.",
@@ -5775,6 +5777,7 @@
"previewEmpty": "(설명 없음)",
"duplicateBody": "이미 활성 작업이 이 항목을 처리 중입니다: {title}({status}). 그래도 새로 만들까요?",
"folderMismatch": "현재 폴더의 원격은 {remote}로, 이 Issue의 저장소와 일치하지 않습니다. 일치하는 폴더를 선택하세요.",
+ "writeMismatch": "이 패널은 {expected}을(를) 표시하고 있었지만 폴더의 원격은 이제 {actual}입니다 — 쓰기는 잘못된 저장소로 보내지지 않고 거부되었습니다. 패널을 새로 고쳤습니다. 다시 시도하세요.",
"cancel": "취소",
"create": "작업 생성",
"creating": "생성 중…",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index 095a75b598..928b038ed0 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -5617,8 +5617,10 @@
},
"Forge": {
"title": "Painel do repositório",
+ "remote": "Remoto",
+ "remoteDefault": "Padrão ({name})",
"pickFolder": "Escolha uma pasta de projeto",
- "noRemote": "Esta pasta não tem um remoto reconhecível (origin)",
+ "noRemote": "Esta pasta não tem um remoto reconhecível",
"errors": {
"noAccount": "Nenhuma conta {provider} configurada para {host}. Adicione uma em Configurações → Controle de versão para carregar este repositório.",
"unsupportedHost": "O painel do repositório só oferece suporte a GitHub, GitLab e Gitea, e {host} não é reconhecido como nenhum deles. Se for uma instância auto-hospedada do GitHub Enterprise, GitLab, Gitea ou Forgejo, adicione uma conta para ela em Configurações → Controle de versão.",
@@ -5775,6 +5777,7 @@
"previewEmpty": "(sem descrição)",
"duplicateBody": "Já existe uma tarefa ativa para este item: {title} ({status}). Criar outra mesmo assim?",
"folderMismatch": "O remoto desta pasta é {remote}, não o repositório deste issue — escolha a pasta correspondente.",
+ "writeMismatch": "Este painel mostrava {expected}, mas o remoto da pasta agora é {actual} — a gravação foi recusada em vez de enviada para o repositório errado. O painel foi atualizado; tente novamente.",
"cancel": "Cancelar",
"create": "Criar tarefa",
"creating": "Criando…",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index dc4e9eeeed..8a7bf63d27 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -5617,8 +5617,10 @@
},
"Forge": {
"title": "仓库面板",
+ "remote": "远端",
+ "remoteDefault": "默认({name})",
"pickFolder": "选择项目文件夹",
- "noRemote": "该文件夹没有可识别的代码托管远端(origin)",
+ "noRemote": "该文件夹没有可识别的代码托管远端",
"errors": {
"noAccount": "还没有为 {host} 配置 {provider} 账号。请在「设置 → 版本控制」中添加后再加载该仓库。",
"unsupportedHost": "仓库面板目前仅支持 GitHub、GitLab 和 Gitea,而 {host} 不是其中任何一种。如果它是自建的 GitHub Enterprise、GitLab、Gitea 或 Forgejo 实例,请在「设置 → 版本控制」中为它添加账号。",
@@ -5775,6 +5777,7 @@
"previewEmpty": "(无描述)",
"duplicateBody": "已有一个活跃任务在处理该条目:{title}({status})。仍要再建一个吗?",
"folderMismatch": "当前文件夹的远端是 {remote},与该 Issue 的仓库不一致——请选择匹配的文件夹。",
+ "writeMismatch": "这个面板显示的是 {expected},但该文件夹的远端现在是 {actual} —— 写入已被拒绝,而不是发到错误的仓库。面板已刷新,请重试。",
"cancel": "取消",
"create": "创建待办任务",
"creating": "创建中…",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index d5500e9e49..c67e9aff88 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -5617,8 +5617,10 @@
},
"Forge": {
"title": "儲存庫面板",
+ "remote": "遠端",
+ "remoteDefault": "預設({name})",
"pickFolder": "選擇專案資料夾",
- "noRemote": "該資料夾沒有可識別的程式碼託管遠端(origin)",
+ "noRemote": "該資料夾沒有可識別的程式碼託管遠端",
"errors": {
"noAccount": "尚未為 {host} 設定 {provider} 帳號。請先在「設定 → 版本控制」中新增,才能載入這個儲存庫。",
"unsupportedHost": "儲存庫面板目前僅支援 GitHub、GitLab 與 Gitea,而 {host} 不屬於其中任何一種。如果它是自架的 GitHub Enterprise、GitLab、Gitea 或 Forgejo 執行個體,請在「設定 → 版本控制」中為它新增帳號。",
@@ -5775,6 +5777,7 @@
"previewEmpty": "(無描述)",
"duplicateBody": "已有一個進行中的任務在處理該條目:{title}({status})。仍要再建一個嗎?",
"folderMismatch": "目前資料夾的遠端是 {remote},與該 Issue 的儲存庫不一致——請選擇相符的資料夾。",
+ "writeMismatch": "這個面板顯示的是 {expected},但該資料夾的遠端現在是 {actual} —— 寫入已被拒絕,而不是送到錯誤的儲存庫。面板已重新整理,請重試。",
"cancel": "取消",
"create": "建立待辦任務",
"creating": "建立中…",
diff --git a/src/lib/api.ts b/src/lib/api.ts
index e8e62b3fc8..d784032a85 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -33,6 +33,7 @@ import type {
ForgeComment,
ForgeCreateResult,
ForgeCommentList,
+ ForgeExpectedRepo,
ForgeIdentity,
ForgeIssueList,
ForgeIssueRow,
@@ -41,6 +42,7 @@ import type {
ForgeMergeOptions,
ForgePanelSettings,
ForgeRemote,
+ ForgeRemoteStore,
ForgeSettingsStore,
ForgeSort,
ForgeStateAction,
@@ -5698,6 +5700,22 @@ export async function forgeListComments(
})
}
+/**
+ * The pair a write should carry, from the repository the panel is SHOWING.
+ *
+ * `null` for a folder with nothing readable on screen: such a write is refused
+ * by the resolution itself, so there is nothing to compare it against.
+ */
+export function forgeExpectedRepo(
+ remote: Pick | null | undefined
+): ForgeExpectedRepo | null {
+ if (remote == null) return null
+ return {
+ expectedServerHost: remote.server_host,
+ expectedOwnerRepo: remote.owner_repo,
+ }
+}
+
/**
* Post one comment, and get back the comment as the FORGE stored it.
*
@@ -5716,7 +5734,8 @@ export async function forgeCreateComment(
number: number
body: string
accountId?: string | null
- }
+ },
+ expected?: ForgeExpectedRepo | null
): Promise {
return getTransport().call("forge_create_comment", {
folderId,
@@ -5725,6 +5744,7 @@ export async function forgeCreateComment(
number: draft.number,
body: draft.body,
accountId: draft.accountId ?? null,
+ ...(expected ?? {}),
},
})
}
@@ -5744,7 +5764,8 @@ export async function forgeSetItemState(
number: number
action: ForgeStateAction
accountId?: string | null
- }
+ },
+ expected?: ForgeExpectedRepo | null
): Promise {
return getTransport().call("forge_set_item_state", {
folderId,
@@ -5753,6 +5774,7 @@ export async function forgeSetItemState(
number: request.number,
action: request.action,
accountId: request.accountId ?? null,
+ ...(expected ?? {}),
},
})
}
@@ -5767,7 +5789,8 @@ export async function forgeCreateIssue(
body?: string | null
labels?: string[]
accountId?: string | null
- }
+ },
+ expected?: ForgeExpectedRepo | null
): Promise {
return getTransport().call("forge_create_issue", {
folderId,
@@ -5776,6 +5799,7 @@ export async function forgeCreateIssue(
body: draft.body ?? null,
labels: draft.labels ?? [],
accountId: draft.accountId ?? null,
+ ...(expected ?? {}),
},
})
}
@@ -5876,7 +5900,8 @@ export async function forgeMergeChange(
method: ForgeMergeMethod
headSha?: string | null
accountId?: string | null
- }
+ },
+ expected?: ForgeExpectedRepo | null
): Promise {
return getTransport().call("forge_merge_change", {
folderId,
@@ -5885,6 +5910,7 @@ export async function forgeMergeChange(
method: request.method,
headSha: request.headSha ?? null,
accountId: request.accountId ?? null,
+ ...(expected ?? {}),
},
})
}
@@ -5927,3 +5953,26 @@ export async function forgeSettingsSet(
): Promise {
return getTransport().call("forge_settings_set", { folderId, settings })
}
+
+/** Every folder's remote selection at once — what the panel's picker reads.
+ * Held as the whole store so switching folders costs no round trip, and a
+ * selection that no longer resolves is still shown for what the folder is set
+ * to. */
+export async function forgeRemoteGet(): Promise {
+ return getTransport().call("forge_remote_get", {})
+}
+
+/**
+ * Save ONE folder's remote selection and get every folder's back as stored.
+ *
+ * `remote = null` (or a blank name) puts the folder back on the default
+ * remote — the picker's "default (origin)" answer. Its own command rather than
+ * a field on the settings save: the picker writes this on every click, and a
+ * settings save must not be able to take it away.
+ */
+export async function forgeRemoteSet(
+ folderId: number,
+ remote: string | null
+): Promise {
+ return getTransport().call("forge_remote_set", { folderId, remote })
+}
diff --git a/src/lib/app-error.ts b/src/lib/app-error.ts
index 9ddaab9e53..8854ba1ec0 100644
--- a/src/lib/app-error.ts
+++ b/src/lib/app-error.ts
@@ -69,6 +69,20 @@ export function extractAppCommandError(error: unknown): AppCommandError | null {
// If the backend enum ever renames, both sides must change together.
export const NOT_A_GIT_REPO_CODE = "not_a_git_repository"
+// Must mirror `WRITE_MISMATCH_I18N_KEY` in src-tauri/src/forge/mod.rs. A WRITE
+// that carried coordinates no longer matching the folder's remote comes back
+// with this key, and the panel's job is to re-resolve the repository rather
+// than leave the reader on one the folder has left.
+export const FORGE_WRITE_MISMATCH_I18N_KEY = "Forge.writeMismatch"
+
+/** Whether this failure is that refusal — the one thing a caller must ACT on
+ * rather than merely report. */
+export function isForgeWriteMismatch(error: unknown): boolean {
+ return (
+ extractAppCommandError(error)?.i18n_key === FORGE_WRITE_MISMATCH_I18N_KEY
+ )
+}
+
export function isNotAGitRepoError(error: unknown): boolean {
const appError = extractAppCommandError(error)
if (appError?.code === NOT_A_GIT_REPO_CODE) return true
diff --git a/src/lib/forge-settings.ts b/src/lib/forge-settings.ts
index 1db8ac22d4..5c0fe70ce7 100644
--- a/src/lib/forge-settings.ts
+++ b/src/lib/forge-settings.ts
@@ -1,5 +1,15 @@
import type { ForgePanelSettings, ForgeSettingsStore } from "@/lib/types"
+/** The built-in defaults for one scope — mirrors `ForgePanelSettings::default`
+ * (write-back on; everything else unset). Shared so a caller that needs a base
+ * to spread over does not enumerate the fields it does not edit. */
+export const DEFAULT_FORGE_PANEL_SETTINGS: ForgePanelSettings = {
+ default_issue_scenario: null,
+ default_pr_scenario: null,
+ writeback_default: true,
+ scenario_prompts: {},
+}
+
/**
* Sentinel folder id of the global row — the same one the task settings dialog
* uses for its own "all folders" scope, so the two surfaces speak one language.
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 8aee63540c..1152b05cde 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -1691,6 +1691,11 @@ export interface ForgeSourceMeta {
head_ref?: string | null
head_sha?: string | null
head_repo?: string | null
+ /** The repository the task's work is pushed to when it is not the source —
+ * the folder's `origin` recorded at trigger time, for the fork workflow
+ * (the panel reads the parent; the branch codeg writes is the user's own
+ * copy). Absent = push to the source. */
+ fork_repo?: string | null
/** URL of the PR created by the delivery acceptance path (P1). */
result_pr?: string | null
/** The trigger dialog's write-back answer, frozen at trigger time. Absent on
@@ -1986,8 +1991,27 @@ export interface ForgeChangedFileList {
has_next: boolean
}
+/**
+ * Which repository a WRITE believes it is writing to — mirrors
+ * `forge::ExpectedCoordinates`.
+ *
+ * Sent flat beside a write's own fields, checked against what the folder's
+ * remote resolves to at that moment, and refused — never redirected — when the
+ * two disagree. That is what stops a second window or a stale browser tab from
+ * posting, closing, filing or merging into the repository the selection has
+ * since moved to. Absent on a request from a build that predates the check,
+ * which keeps behaving exactly as it did.
+ */
+export interface ForgeExpectedRepo {
+ expectedServerHost: string
+ expectedOwnerRepo: string
+}
+
/** A folder's `origin` remote parsed into forge coordinates. */
export interface ForgeRemote {
+ /** Which remote this was resolved from — the panel shows it so the active
+ * choice is visible rather than inferred from the URL. */
+ remote_name: string
server_host: string
owner_repo: string
remote_url: string
@@ -2097,6 +2121,20 @@ export interface ForgeSettingsStore {
/** Reserved `scenario_prompts` key applied to every scenario. */
export const FORGE_SCENARIO_PROMPT_ALL = "all"
+/** Which git remote each folder's forge panel reads — mirrors
+ * `forge::remotes::ForgeRemoteStore`.
+ *
+ * Deliberately NOT a field of `ForgePanelSettings`: the picker saves this on
+ * every click, while the panel settings are a blob the settings dialog
+ * rewrites wholesale — so one field living in the other's blob is how "use
+ * global defaults" came to destroy a choice the picker had already saved. */
+export interface ForgeRemoteStore {
+ /** Keyed by folder id (JSON has no integer keys, so they arrive as strings).
+ * A folder with no entry reads the historical `origin` — absence IS the
+ * default answer, so there is no global row to fall back to. */
+ folders: Record
+}
+
/** Discriminated trigger outcome — duplicate/mismatch are answers, not errors. */
export type ForgeCreateResult =
| { outcome: "created"; task: WorkTask }