aacd63b85b
A new workspace takes the default AI tool (Settings → AI Tools, the account's own pick first); each session still picks its own AI on the workspace home. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
838 lines
33 KiB
TypeScript
838 lines
33 KiB
TypeScript
/**
|
||
* @file AddLaneModal.tsx
|
||
* @description The "+ Add lane" flow, in one of three modes chosen with a
|
||
* segmented toggle — "Repo" by default; a branch prefill (the git bar's "new
|
||
* workspace from this branch") opens on "Worktree": "Repo" adopts an existing directory as-is via
|
||
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
||
* main repo you want stage detection on); "Worktree" provisions a
|
||
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||
* Settings → Git). There is no AI tool picker: the lane is created with the
|
||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||
* AI on the workspace home. Either
|
||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
||
* opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
|
||
* which a click into the field opens too.
|
||
* The worktree lane returned is `status: "provisioning"`:
|
||
* the route answers before the actual `git worktree add` runs, so this
|
||
* component polls `GET /api/lanes/:id` until that finishes before running the
|
||
* auto-setup calls against a cwd that must actually exist on disk first.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { X } from "lucide-react";
|
||
import { useDialog } from "../../hooks/useDialog";
|
||
import { CwdAutocomplete } from "../run/RunSetup";
|
||
import { PathPickButton } from "../PathPickButton";
|
||
import { api } from "../../lib/api";
|
||
import type { CwdSuggestion } from "../../lib/api";
|
||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||
import { useAiTools } from "../../hooks/useAiTools";
|
||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||
|
||
/** Polls the lane until the background `git worktree add` finishes (status
|
||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||
* lane record, or `null` on timeout. */
|
||
async function waitForProvisioned(
|
||
laneId: number,
|
||
{ intervalMs = 500, timeoutMs = 30000 } = {}
|
||
): Promise<Lane | null> {
|
||
const deadline = Date.now() + timeoutMs;
|
||
for (;;) {
|
||
const { lane } = await api.lanes.get(laneId);
|
||
if (lane.status !== "provisioning") return lane;
|
||
if (Date.now() >= deadline) return null;
|
||
await new Promise((r) => window.setTimeout(r, intervalMs));
|
||
}
|
||
}
|
||
|
||
/** The folder `git clone <url>` would make: the last path segment, no `.git`. */
|
||
export function cloneDirName(url: string): string {
|
||
return (
|
||
url
|
||
.trim()
|
||
.replace(/[/:]+$/, "")
|
||
.split(/[/:]/)
|
||
.pop() || ""
|
||
).replace(/\.git$/i, "");
|
||
}
|
||
|
||
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
|
||
function Seg({
|
||
active,
|
||
label,
|
||
title,
|
||
onClick,
|
||
}: {
|
||
active: boolean;
|
||
label: string;
|
||
title?: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
title={title}
|
||
aria-pressed={active}
|
||
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||
active
|
||
? "bg-accent-solid text-white shadow-sm"
|
||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function AddLaneModal({
|
||
open,
|
||
onClose,
|
||
onAdded,
|
||
cwdSuggestions,
|
||
prefill,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
/** Called with the newly created (possibly still-provisioning) lane. */
|
||
onAdded: (lane: Lane) => void;
|
||
/** Fields to open with, for "new lane from this repo" in a lane's git bar.
|
||
* A branch here means the sibling worktree checks out THAT branch, which
|
||
* is the existing-branch mode — the reason that mode exists (issue #34). */
|
||
prefill?: { sourceRepo?: string; branch?: string } | null;
|
||
/** The same suggestion list the Run form already fetched (dashboard cwd,
|
||
* home, recently-used paths) — reused rather than fetched a second time. */
|
||
cwdSuggestions: CwdSuggestion[];
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
const [mode, setMode] = useState<"repo" | "worktree" | "clone">("repo");
|
||
// Clone mode: the URL, the folder name (blank = the URL's), and — once the
|
||
// server asked for one — a login for the URL's https host.
|
||
const [cloneUrl, setCloneUrl] = useState("");
|
||
const [cloneName, setCloneName] = useState("");
|
||
const [login, setLogin] = useState<{
|
||
host: string;
|
||
username: string;
|
||
token: string;
|
||
remember: boolean;
|
||
} | null>(null);
|
||
const [sshRefused, setSshRefused] = useState(false);
|
||
const [sourceRepo, setSourceRepo] = useState("");
|
||
const [title, setTitle] = useState("");
|
||
const [branch, setBranch] = useState("");
|
||
const [branches, setBranches] = useState<string[] | null>(null);
|
||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||
// Two ways to start a worktree lane, and git's own rule decides which is
|
||
// possible: "new" forks a branch off `base`, "existing" checks out a branch
|
||
// that is already there — which git allows exactly once across all of a
|
||
// repo's worktrees, hence the busy markers below.
|
||
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
|
||
const [base, setBase] = useState("");
|
||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||
// No picker: a workspace's tool is only its DEFAULT — each new session
|
||
// picks its own AI on the workspace home. This is the default AI tool
|
||
// (Settings → AI Tools), the account's own pick first while it is
|
||
// installed and on.
|
||
const aiTools = useAiTools();
|
||
const ownTool = readDefaultTool();
|
||
const tool =
|
||
(ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled)
|
||
? ownTool
|
||
: null) ??
|
||
aiTools.defaultId ??
|
||
"claude";
|
||
const noTools = aiTools.loaded && aiTools.defaultId === null;
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [setupResult, setSetupResult] = useState<{
|
||
profile: "scaffolded" | "skipped" | "failed";
|
||
mcp: "ok" | "failed";
|
||
} | null>(null);
|
||
|
||
const reset = () => {
|
||
setMode("repo");
|
||
setCloneUrl("");
|
||
setCloneName("");
|
||
setLogin(null);
|
||
setSshRefused(false);
|
||
setSourceRepo("");
|
||
setTitle("");
|
||
setBranch("");
|
||
setBranches(null);
|
||
setWorktrees([]);
|
||
setBranchMode("new");
|
||
setBase("");
|
||
setBranchesError(null);
|
||
setError(null);
|
||
setBusy(false);
|
||
setSetupResult(null);
|
||
};
|
||
|
||
// A prefill only ever seeds the fields ON OPEN: the user may then change
|
||
// anything, and re-applying it on every render would fight them for the box.
|
||
useEffect(() => {
|
||
if (!open || !prefill) return;
|
||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||
if (prefill.branch) {
|
||
// A branch only means something to a worktree.
|
||
setMode("worktree");
|
||
setBranch(prefill.branch);
|
||
setBranchMode("existing");
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open]);
|
||
|
||
// Look up the repo's branches once the path settles - debounced so every
|
||
// keystroke while typing a path doesn't fire a request against a path that
|
||
// isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and
|
||
// never forks a branch.
|
||
const lookedUpFor = useRef<string>("");
|
||
useEffect(() => {
|
||
if (mode !== "worktree") return;
|
||
const path = sourceRepo.trim();
|
||
if (!path) {
|
||
setBranches(null);
|
||
setWorktrees([]);
|
||
setBase("");
|
||
setBranchesError(null);
|
||
return;
|
||
}
|
||
const timer = window.setTimeout(async () => {
|
||
lookedUpFor.current = path;
|
||
try {
|
||
const r = await api.lanes.branches(path);
|
||
if (lookedUpFor.current !== path) return; // a newer path superseded this one
|
||
setBranches(r.branches);
|
||
setWorktrees(r.worktrees || []);
|
||
setBase(r.current || r.branches[0] || "");
|
||
setBranchesError(null);
|
||
} catch {
|
||
if (lookedUpFor.current !== path) return;
|
||
// Not yet a valid repo path (still being typed, or genuinely wrong) -
|
||
// quiet by design, the same way CwdAutocomplete never errors either.
|
||
setBranches(null);
|
||
setWorktrees([]);
|
||
setBase("");
|
||
setBranchesError(t("addLaneNotARepo"));
|
||
}
|
||
}, 300);
|
||
return () => window.clearTimeout(timer);
|
||
}, [mode, sourceRepo, t]);
|
||
|
||
const submit = async () => {
|
||
const repo = sourceRepo.trim();
|
||
const name = title.trim();
|
||
if (!repo) return;
|
||
setBusy(true);
|
||
setError(null);
|
||
|
||
if (mode === "clone") {
|
||
const url = cloneUrl.trim();
|
||
if (!url) return;
|
||
setSshRefused(false);
|
||
try {
|
||
const result = await api.lanes.clone({
|
||
url,
|
||
parent: repo,
|
||
name: cloneName.trim() || undefined,
|
||
title: name || undefined,
|
||
tool,
|
||
...(login?.username.trim() && login.token
|
||
? {
|
||
credentials: {
|
||
username: login.username.trim(),
|
||
password: login.token,
|
||
remember: login.remember,
|
||
},
|
||
}
|
||
: {}),
|
||
});
|
||
onAdded(result.lane);
|
||
reset();
|
||
onClose();
|
||
} catch (err) {
|
||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||
const host = e.auth?.host || url;
|
||
if (e.code === "ECREDENTIALS") {
|
||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||
setLogin((l) => ({
|
||
host,
|
||
username: l?.username ?? "",
|
||
token: "",
|
||
remember: l?.remember ?? true,
|
||
}));
|
||
} else if (e.code === "ESSHAUTH") {
|
||
setSshRefused(true);
|
||
setError(t("clone.sshRefused", { host }));
|
||
} else {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
setBusy(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mode === "repo") {
|
||
try {
|
||
const result = await api.lanes.ensure({
|
||
cwd: repo,
|
||
title: name || undefined,
|
||
tool,
|
||
// THIS directory, not a lane on a parent of it (#42).
|
||
exact: true,
|
||
});
|
||
// Report the lane the INSTANT it exists server-side. Everything after
|
||
// this is post-creation setup that can fail on its own, and letting one
|
||
// of those failures skip `onAdded` would leave a real lane invisible in
|
||
// the list until some unrelated refetch happened to pick it up.
|
||
// Worktree mode below already works this way.
|
||
onAdded(result.lane);
|
||
// Nothing was created: closing now reads as success while no new card
|
||
// appears (#42). Say which lane already owns the folder instead.
|
||
if (!result.created) {
|
||
setError(
|
||
t("addLaneExists", {
|
||
id: result.lane.id,
|
||
title: result.lane.title || result.lane.cwd,
|
||
})
|
||
);
|
||
setBusy(false);
|
||
return;
|
||
}
|
||
reset();
|
||
onClose();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
setBusy(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// A blank branch in "new" mode is legal and deliberate: the server names
|
||
// it `${LANE_BRANCH_PREFIX}${slug}`, which is what the placeholder previews.
|
||
if (!branches || !name || (branchMode === "existing" && !branch.trim())) return;
|
||
try {
|
||
const result = await api.lanes.worktree({
|
||
sourceRepo: repo,
|
||
title: name,
|
||
// An existing branch is checked out as it stands; `base` only means
|
||
// something when a branch is being forked.
|
||
base: branchMode === "new" ? base || undefined : undefined,
|
||
branch: branch.trim() || undefined,
|
||
tool,
|
||
});
|
||
onAdded(result.lane);
|
||
|
||
// POST /worktree returns as soon as the DB row exists (202) — the
|
||
// actual `git worktree add` runs afterward, in the background, on the
|
||
// server. Firing setup against the lane's cwd before that finishes
|
||
// means mcp writes into a directory that doesn't exist yet, so
|
||
// wait for provisioning to leave the "provisioning" status first.
|
||
const provisionedLane = await waitForProvisioned(result.lane.id);
|
||
if (!provisionedLane || provisionedLane.status === "failed") {
|
||
setBusy(false);
|
||
setError(t("addLaneProvisionFailed"));
|
||
return;
|
||
}
|
||
|
||
const [profileOutcome, mcpOutcome] = await Promise.allSettled([
|
||
api.lanes.profileInit(result.lane.id),
|
||
api.lanes.mcpSync(result.lane.id),
|
||
]);
|
||
const outcome = {
|
||
profile:
|
||
profileOutcome.status === "fulfilled"
|
||
? profileOutcome.value.scaffolded
|
||
? "scaffolded"
|
||
: "skipped"
|
||
: "failed",
|
||
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
|
||
} as const;
|
||
const anyFailed = Object.values(outcome).includes("failed");
|
||
setSetupResult(outcome);
|
||
|
||
if (import.meta.env.DEV) {
|
||
console.info("[add-lane] auto-setup result:", {
|
||
profile:
|
||
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
|
||
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
|
||
});
|
||
}
|
||
|
||
// A clean setup has nothing to tell anyone, so the lane is made and the
|
||
// dialog is done — leaving it up read as "still working" (the confirm
|
||
// button is dead from here, since the lane already exists) with no
|
||
// obvious way out. A summary is kept on screen only when a step FAILED,
|
||
// and then the confirm button becomes a plain close, because that is the
|
||
// one case where the user has something to read before dismissing it.
|
||
setBusy(false);
|
||
if (!anyFailed) {
|
||
reset();
|
||
onClose();
|
||
}
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
// Escape, the backdrop, the X and Cancel all land here. A folder browser
|
||
// open on top answers its own Escape and backdrop (PathPickButton).
|
||
const handleCancel = useCallback(() => {
|
||
reset();
|
||
onClose();
|
||
}, [onClose]);
|
||
|
||
// Cancel takes focus on open, the safer default.
|
||
const titleId = useId();
|
||
const panelRef = useRef<HTMLDivElement>(null);
|
||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||
useDialog(open, panelRef, handleCancel, cancelRef);
|
||
|
||
// A summary on screen means the lane already exists and a step failed, so
|
||
// the confirm button has no second creation to run: it closes instead. That
|
||
// is the difference between a dialog that is finished and one that is stuck.
|
||
const done = !!setupResult;
|
||
const disabled =
|
||
!done &&
|
||
(!sourceRepo.trim() ||
|
||
(mode === "clone" &&
|
||
(!cloneUrl.trim() || (!!login && (!login.username.trim() || !login.token)))) ||
|
||
(mode === "worktree" &&
|
||
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
||
|
||
/** branch name -> the worktree directory already holding it. git allows one
|
||
* checkout per branch, so these are the branches this form cannot use. */
|
||
const busyBranches = new Map(
|
||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||
);
|
||
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
|
||
|
||
if (!open) return null;
|
||
|
||
// Its own shell, not ConfirmModal: that one lays its body beside the title in
|
||
// the X button's row, which left this form narrower than its own footer.
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
|
||
onClick={handleCancel}
|
||
role="presentation"
|
||
>
|
||
<div
|
||
ref={panelRef}
|
||
className="relative flex max-h-[90vh] w-full max-w-lg flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||
onClick={(e) => e.stopPropagation()}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={titleId}
|
||
data-tour="add-lane"
|
||
>
|
||
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
|
||
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
|
||
{t("addLane")}
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
onClick={handleCancel}
|
||
className="btn-icon -mr-1"
|
||
aria-label={t("destructive.cancel")}
|
||
>
|
||
<X className="h-4 w-4" aria-hidden />
|
||
</button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||
<Seg
|
||
active={mode === "repo"}
|
||
label={t("mode.repo")}
|
||
title={t("mode.repoHint")}
|
||
onClick={() => setMode("repo")}
|
||
/>
|
||
<Seg
|
||
active={mode === "worktree"}
|
||
label={t("mode.worktree")}
|
||
title={t("mode.worktreeHint")}
|
||
onClick={() => setMode("worktree")}
|
||
/>
|
||
<Seg
|
||
active={mode === "clone"}
|
||
label={t("mode.clone")}
|
||
title={t("mode.cloneHint")}
|
||
onClick={() => setMode("clone")}
|
||
/>
|
||
</div>
|
||
|
||
{mode === "clone" && (
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-url">
|
||
{t("clone.urlLabel")}
|
||
</label>
|
||
<input
|
||
id="add-lane-url"
|
||
value={cloneUrl}
|
||
onChange={(e) => {
|
||
setCloneUrl(e.target.value);
|
||
// A login belongs to the host it was asked for.
|
||
setLogin(null);
|
||
setSshRefused(false);
|
||
}}
|
||
placeholder="https://github.com/owner/repo.git"
|
||
spellCheck={false}
|
||
autoComplete="off"
|
||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||
/>
|
||
<p className="mt-1 text-[10px] text-fg-muted">{t("clone.urlHint")}</p>
|
||
</div>
|
||
)}
|
||
|
||
{noTools && (
|
||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||
{t("addLaneNoTools")}{" "}
|
||
<a href="/settings/agents" className="underline">
|
||
{t("addLaneNoToolsLink")}
|
||
</a>
|
||
</p>
|
||
)}
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||
{mode === "repo"
|
||
? t("addLaneRepoLabelAdopt")
|
||
: mode === "clone"
|
||
? t("clone.parentLabel")
|
||
: t("addLaneRepoLabel")}
|
||
</label>
|
||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||
{/* A click into the path only focuses it — the picker opens from
|
||
Browse alone (#77). */}
|
||
<div className="min-w-0 flex-1">
|
||
<CwdAutocomplete
|
||
inputId="add-lane-repo"
|
||
value={sourceRepo}
|
||
onChange={setSourceRepo}
|
||
suggestions={cwdSuggestions}
|
||
/>
|
||
</div>
|
||
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
||
</div>
|
||
<p className="mt-1 text-[10px] text-fg-muted">
|
||
{mode === "clone"
|
||
? t("clone.parentHint", {
|
||
path: `${sourceRepo.trim().replace(/[\\/]+$/, "") || "…"}/${
|
||
cloneName.trim() || cloneDirName(cloneUrl) || "…"
|
||
}`,
|
||
})
|
||
: mode === "repo"
|
||
? t("addLaneRepoHintAdopt")
|
||
: t("addLaneRepoHint")}
|
||
</p>
|
||
</div>
|
||
|
||
{mode === "clone" && (
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-clone-name">
|
||
{t("clone.nameLabel")}
|
||
</label>
|
||
<input
|
||
id="add-lane-clone-name"
|
||
value={cloneName}
|
||
onChange={(e) => setCloneName(e.target.value)}
|
||
placeholder={cloneDirName(cloneUrl)}
|
||
spellCheck={false}
|
||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{mode === "clone" && login && (
|
||
<div
|
||
data-testid="add-lane-clone-login"
|
||
className="grid gap-2 rounded-md border border-border-light bg-surface-0 p-2 sm:grid-cols-2"
|
||
>
|
||
<input
|
||
aria-label={t("clone.username")}
|
||
placeholder={t("clone.username")}
|
||
autoComplete="off"
|
||
value={login.username}
|
||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||
/>
|
||
<input
|
||
type="password"
|
||
aria-label={t("clone.token")}
|
||
placeholder={t("clone.token")}
|
||
autoComplete="new-password"
|
||
value={login.token}
|
||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||
/>
|
||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary sm:col-span-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={login.remember}
|
||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||
/>
|
||
{t("clone.remember")}
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{/* What this repo already has checked out. Without it the form asked
|
||
for a branch in the dark: the one fact that decides whether a
|
||
branch can be used at all is which worktree is holding it. */}
|
||
{mode === "worktree" && worktrees.length > 0 && (
|
||
<div
|
||
data-testid="add-lane-worktrees"
|
||
className="rounded-md border border-border-light bg-surface-0 p-2"
|
||
>
|
||
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
|
||
{t("addLaneWorktreesTitle", { count: worktrees.length })}
|
||
</p>
|
||
<ul className="space-y-0.5">
|
||
{worktrees.slice(0, 8).map((w) => (
|
||
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
|
||
<span className="shrink-0 font-mono text-fg-primary">
|
||
⑂ {w.branch || t("addLaneWorktreeDetached")}
|
||
</span>
|
||
<span className="truncate text-fg-muted" title={w.path}>
|
||
{w.path}
|
||
</span>
|
||
</li>
|
||
))}
|
||
{worktrees.length > 8 && (
|
||
<li className="text-[10px] text-fg-muted">
|
||
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
|
||
</li>
|
||
)}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
|
||
{t("addLaneTitleLabel")}
|
||
</label>
|
||
<input
|
||
id="add-lane-title"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder={t("addLaneTitlePlaceholder")}
|
||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
{mode === "worktree" && branchesError && !branches && (
|
||
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
||
)}
|
||
|
||
{mode === "worktree" && branches && (
|
||
<div data-testid="add-lane-branch-section" className="space-y-2">
|
||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||
<Seg
|
||
active={branchMode === "new"}
|
||
label={t("addLaneBranchModeNew")}
|
||
onClick={() => {
|
||
setBranchMode("new");
|
||
setBranch("");
|
||
}}
|
||
/>
|
||
<Seg
|
||
active={branchMode === "existing"}
|
||
label={t("addLaneBranchModeExisting")}
|
||
onClick={() => {
|
||
setBranchMode("existing");
|
||
// Land on something usable rather than on an empty select
|
||
// that looks like a choice was already made.
|
||
setBranch((current) =>
|
||
freeBranches.includes(current) ? current : freeBranches[0] || ""
|
||
);
|
||
}}
|
||
/>
|
||
{/* The third answer to "which branch": none. A small or personal
|
||
project usually wants the lane to BE the repo, working on
|
||
whatever is checked out (`main`) — which is exactly Repo
|
||
mode, so this jumps there rather than inventing a second way
|
||
to do it. git cannot check `main` out in a second worktree
|
||
while the repo itself holds it, so a worktree lane could not
|
||
offer this even if it wanted to. */}
|
||
<Seg
|
||
active={false}
|
||
label={t("addLaneBranchModeCurrent")}
|
||
title={t("addLaneBranchModeCurrentHint")}
|
||
onClick={() => setMode("repo")}
|
||
/>
|
||
</div>
|
||
|
||
{branchMode === "new" ? (
|
||
<>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||
{t("addLaneBaseLabel")}
|
||
</label>
|
||
{branches.length === 0 ? (
|
||
// No commit, nothing to fork: the server refuses this with
|
||
// EEMPTYREPO, so the one way forward is offered instead.
|
||
<div className="space-y-1">
|
||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => setMode("repo")}
|
||
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||
>
|
||
{t("addLaneNoBranchesUseRepo")}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<select
|
||
id="add-lane-base"
|
||
value={base}
|
||
onChange={(e) => setBase(e.target.value)}
|
||
className="select w-full !py-1.5 !text-xs"
|
||
>
|
||
{branches.map((b) => (
|
||
<option key={b} value={b}>
|
||
{b}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label
|
||
className="mb-1 block text-xs text-fg-secondary"
|
||
htmlFor="add-lane-branch"
|
||
>
|
||
{t("addLaneBranchLabel")}
|
||
</label>
|
||
<input
|
||
id="add-lane-branch"
|
||
value={branch}
|
||
onChange={(e) => setBranch(e.target.value)}
|
||
// Neutral on purpose: an empty field lets the SERVER name
|
||
// the branch, under a LANE_BRANCH_PREFIX this form cannot
|
||
// see, so any `feat/…` preview here could be wrong.
|
||
placeholder={t("addLaneBranchPlaceholder")}
|
||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||
/>
|
||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div>
|
||
<label
|
||
className="mb-1 block text-xs text-fg-secondary"
|
||
htmlFor="add-lane-existing"
|
||
>
|
||
{t("addLaneBranchExistingLabel")}
|
||
</label>
|
||
{freeBranches.length === 0 ? (
|
||
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
|
||
) : (
|
||
<select
|
||
id="add-lane-existing"
|
||
value={branch}
|
||
onChange={(e) => setBranch(e.target.value)}
|
||
className="select w-full !py-1.5 font-mono !text-xs"
|
||
>
|
||
{branches.map((b) => {
|
||
const busyAt = busyBranches.get(b);
|
||
return (
|
||
// Disabled rather than hidden: "why is my branch not
|
||
// in the list" is a worse question than seeing it
|
||
// greyed out with the worktree that holds it.
|
||
<option key={b} value={b} disabled={!!busyAt}>
|
||
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
)}
|
||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{setupResult && (
|
||
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
|
||
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
|
||
{(
|
||
[
|
||
{
|
||
label: t("addLaneSetupProfile"),
|
||
ok: setupResult.profile !== "failed",
|
||
skipped: setupResult.profile === "skipped",
|
||
},
|
||
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||
] as const
|
||
).map((row) => (
|
||
<p
|
||
key={row.label}
|
||
className="flex items-center gap-1.5 text-[11px] text-fg-primary"
|
||
>
|
||
<span
|
||
className={
|
||
"skipped" in row && row.skipped
|
||
? "text-fg-muted"
|
||
: row.ok
|
||
? "text-status-success"
|
||
: "text-status-danger"
|
||
}
|
||
>
|
||
{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"}
|
||
</span>
|
||
{row.label}
|
||
</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<p role="alert" className="text-xs text-status-danger">
|
||
{error}
|
||
{sshRefused && (
|
||
<>
|
||
{" "}
|
||
<a href="/settings/git#git-identity" className="underline">
|
||
{t("clone.sshSettings")}
|
||
</a>
|
||
</>
|
||
)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
|
||
<button
|
||
ref={cancelRef}
|
||
type="button"
|
||
onClick={handleCancel}
|
||
className="btn-ghost border border-border text-xs"
|
||
>
|
||
{t("destructive.cancel")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={done ? handleCancel : submit}
|
||
disabled={busy || disabled}
|
||
data-tour="add-lane-create"
|
||
className="btn-primary text-xs disabled:opacity-50"
|
||
>
|
||
{done
|
||
? t("addLaneSetupClose")
|
||
: busy && mode === "clone"
|
||
? t("clone.cloning")
|
||
: t("add")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|