aff79a787c
What broke: a lane on a fresh `git init` repo had no working git surface.
`statusCounts` ran `rev-parse --short HEAD` unguarded, so `gitFacts` threw
and `GET /api/lanes/:id/git` answered `{available:false}` — the Changes
panel showed no header, and `commit`/`switch` 500'd through `isDirty`.
Add lane in Worktree mode accepted such a repo and left a `failed` row
behind, and a repo with commits but no remote had a permanently disabled
Push with no way to give it one short of a terminal.
What changed:
- worktree.js: an unborn HEAD is a normal state. `statusCounts` reports
`head: null`; `gitFacts` reads the branch via `symbolic-ref`, an empty
subject, and a new `unborn` field.
- git-ops.js: an 11th write, `publish` — `remote add origin -- <url>` then
`push -u origin HEAD`, only with a commit (ENOCOMMITS) and NO remote at
all (EREMOTEEXISTS, so it never re-points one), the url allowlisted by
`validateRemoteUrl` (https without userinfo, ssh://, scp-like; refuses
local paths, file://, ext::, fd::, git://, http://, leading `-`:
EBADURL), and `origin` removed again when the push fails.
- POST /api/lanes/worktree answers 400 EEMPTYREPO for a repo with no
commits instead of creating a row that can only fail.
- LaneGitBar: unborn repos show "no commits yet" and a commit-first hint;
no remote offers Publish… with an inline URL box; a remote but no
upstream relabels Push as "Publish branch". AddLaneModal disables Add
on an empty repo and offers "Use as a Repo lane".
- The two tests that used an unborn repo to force a provisioning failure
now use a `feat` ref clash, since that repo is refused up front.
- CLAUDE.md rule reworded to "the boring eleven" with publish's guards;
docs/API.md, docs/LANES.md, ARCHITECTURE.md, server/README.md, openapi.
565 lines
22 KiB
TypeScript
565 lines
22 KiB
TypeScript
/**
|
|
* @file The lane's git action row: ahead/behind against the upstream, and the
|
|
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
|
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
|
|
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
|
|
* (the picked branch, or the upstream once diverged; resolving it is the
|
|
* Changes panel's job). Committing is
|
|
* NOT here: it is the Changes panel's staged-only commit box, beside the
|
|
* per-file stage/unstage/discard it commits — one way to commit, and it only
|
|
* ever takes what the user staged.
|
|
*
|
|
* It renders at the top of the Changes panel (`LaneGitHeader`), its actions as
|
|
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
|
|
* worktree`), and these are the ordinary moves a lane needs several times an hour.
|
|
* Nothing here decides what an action may do; the server does (pull is always
|
|
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
|
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
|
* the user more than any sentence this file could write. The one exception
|
|
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
|
* in, so a remote wanting a login opens a form here and the SAME action is
|
|
* resent with the typed credential — the token is never rendered back.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ArrowDown,
|
|
ArrowUp,
|
|
CloudUpload,
|
|
GitBranch,
|
|
GitMerge,
|
|
Plus,
|
|
RefreshCw,
|
|
Trash2,
|
|
Upload,
|
|
} from "lucide-react";
|
|
import { api, type GitAuthNeeded } from "../../lib/api";
|
|
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
|
|
|
type Action =
|
|
| "fetch"
|
|
| "pull"
|
|
| "push"
|
|
| "switch"
|
|
| "branch-create"
|
|
| "branch-delete"
|
|
| "merge"
|
|
| "publish";
|
|
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
|
|
|
|
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
|
|
|
|
function Btn({
|
|
label,
|
|
icon,
|
|
busy,
|
|
disabled,
|
|
title,
|
|
compact,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
icon?: React.ReactNode;
|
|
busy?: boolean;
|
|
disabled?: boolean;
|
|
title?: string;
|
|
/** Icon only; the label becomes the accessible name, the hint the tooltip. */
|
|
compact?: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled || busy}
|
|
title={title}
|
|
aria-label={compact ? label : undefined}
|
|
className={`flex items-center gap-1 rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40 ${
|
|
compact ? "h-6 w-6 justify-center" : "border border-border-light px-1.5 py-0.5"
|
|
}`}
|
|
>
|
|
{icon}
|
|
{!compact && label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function LaneGitBar({
|
|
laneId,
|
|
git,
|
|
onChanged,
|
|
onNewLane,
|
|
lead,
|
|
}: {
|
|
laneId: number;
|
|
git: LaneGitFacts;
|
|
/** Rendered at the start of the action row: the header's branch line, so
|
|
* the facts and the buttons that act on them read as one row. */
|
|
lead?: React.ReactNode;
|
|
/** Called after any action that could have moved the working copy. */
|
|
onChanged: () => void;
|
|
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on
|
|
* a branch this bar just created. Omitted where no such flow is reachable. */
|
|
onNewLane?: (branch?: string) => void;
|
|
}) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const [busy, setBusy] = useState<Action | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [switching, setSwitching] = useState(false);
|
|
const [branches, setBranches] = useState<string[] | null>(null);
|
|
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
|
const [picked, setPicked] = useState("");
|
|
const [newBranch, setNewBranch] = useState("");
|
|
// Set when git needed a login for `action`; the form resends that action.
|
|
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [remember, setRemember] = useState(true);
|
|
const [publishing, setPublishing] = useState(false);
|
|
const [url, setUrl] = useState("");
|
|
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
|
|
// All of the state above describes ONE lane — a branch picked for lane A,
|
|
// applied after switching, would move B's HEAD — so it is reset during
|
|
// render the moment the id changes, before anything can act on it.
|
|
const [shownLane, setShownLane] = useState(laneId);
|
|
// One action at a time, checked synchronously: `busy` is state, and a second
|
|
// Enter can land before the render that disables anything.
|
|
const inFlight = useRef(false);
|
|
if (shownLane !== laneId) {
|
|
setShownLane(laneId);
|
|
setBusy(null);
|
|
setError(null);
|
|
setSwitching(false);
|
|
setBranches(null);
|
|
setWorktrees([]);
|
|
setPicked("");
|
|
setNewBranch("");
|
|
setLogin(null);
|
|
setPassword("");
|
|
setPublishing(false);
|
|
setUrl("");
|
|
inFlight.current = false;
|
|
}
|
|
const laneNow = useRef(laneId);
|
|
laneNow.current = laneId;
|
|
|
|
if (!git.available) return null;
|
|
const dirty = git.dirty + git.untracked;
|
|
const merging = !!git.merging;
|
|
|
|
const run = async (action: Action, body?: RunBody) => {
|
|
if (inFlight.current) return;
|
|
inFlight.current = true;
|
|
const lane = laneId;
|
|
setBusy(action);
|
|
setError(null);
|
|
try {
|
|
const res = await api.lanes.gitAction(lane, action, body || {});
|
|
// Landed after a lane switch: every write below belongs to the lane
|
|
// no longer shown.
|
|
if (laneNow.current !== lane) return;
|
|
// A merge that stopped on conflicts still succeeded in starting; the
|
|
// Changes panel below lists the files to resolve.
|
|
if (res?.conflicts?.length)
|
|
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
|
|
setLogin(null);
|
|
setPassword("");
|
|
// The branch list this bar is showing is stale the moment a branch is
|
|
// created, deleted or switched to, so it is re-read rather than patched.
|
|
if (action === "publish") {
|
|
setPublishing(false);
|
|
setUrl("");
|
|
} else if (action === "switch" || action.startsWith("branch-")) {
|
|
if (action === "switch") setSwitching(false);
|
|
else setNewBranch("");
|
|
setPicked("");
|
|
setBranches(null);
|
|
await refreshBranches();
|
|
}
|
|
onChanged();
|
|
} catch (err) {
|
|
// The server appends git's own stderr to the message, so this is the
|
|
// rejection text verbatim — hence the <pre> below rather than a line.
|
|
// ENOREMOTE is the one refusal with no git text to show: the server's
|
|
// sentence is English and says less than a translated one.
|
|
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
|
|
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
|
|
setLogin({ action, auth });
|
|
setUsername((u) => u || auth.username || "");
|
|
setPassword("");
|
|
} else if (laneNow.current === lane)
|
|
setError(
|
|
(err as { code?: string })?.code === "ENOREMOTE"
|
|
? t("gitBar.errNoRemote")
|
|
: err instanceof Error
|
|
? err.message
|
|
: String(err)
|
|
);
|
|
} finally {
|
|
if (laneNow.current === lane) {
|
|
inFlight.current = false;
|
|
setBusy(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const refreshBranches = async () => {
|
|
try {
|
|
const r = await api.lanes.gitBranches(laneId);
|
|
if (laneNow.current !== laneId) return;
|
|
setBranches(r.branches);
|
|
setWorktrees(r.worktrees || []);
|
|
} catch {
|
|
if (laneNow.current === laneId) setBranches([]);
|
|
}
|
|
};
|
|
|
|
const openSwitch = async () => {
|
|
setSwitching((v) => !v);
|
|
setError(null);
|
|
if (branches) return;
|
|
await refreshBranches();
|
|
};
|
|
|
|
const merge = (branch: string) => {
|
|
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
|
|
void run("merge", { branch });
|
|
};
|
|
|
|
const heldBy = new Map(
|
|
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
|
);
|
|
|
|
return (
|
|
<div data-testid="lane-git-bar" className="space-y-1">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
{lead}
|
|
{/* Ahead/behind first: it is the fact that decides which of the
|
|
buttons beside it the user actually wants. */}
|
|
{/* Null counts with an upstream named means git could not compare
|
|
the two (the upstream ref is gone): claiming "in sync" there
|
|
would be a lie, so it reads as not pushed. */}
|
|
{git.unborn ? (
|
|
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
|
{t("gitBar.noCommits")}
|
|
</span>
|
|
) : git.upstream && git.ahead != null && git.behind != null ? (
|
|
<span
|
|
data-testid="lane-git-tracking"
|
|
title={git.upstream}
|
|
className="flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
|
|
>
|
|
{(git.ahead ?? 0) > 0 && (
|
|
<span className="flex items-center text-status-warning">
|
|
<ArrowUp className="h-3 w-3" />
|
|
{git.ahead}
|
|
</span>
|
|
)}
|
|
{(git.behind ?? 0) > 0 && (
|
|
<span className="flex items-center text-accent">
|
|
<ArrowDown className="h-3 w-3" />
|
|
{git.behind}
|
|
</span>
|
|
)}
|
|
{!git.ahead && !git.behind && <span>{t("gitBar.inSync")}</span>}
|
|
</span>
|
|
) : (
|
|
git.hasRemote && (
|
|
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
|
{t("gitBar.noUpstream")}
|
|
</span>
|
|
)
|
|
)}
|
|
|
|
{/* Diverged: a --ff-only pull can only refuse, so offer the merge. */}
|
|
{git.upstream && (git.ahead ?? 0) > 0 && (git.behind ?? 0) > 0 && !merging && (
|
|
<Btn
|
|
label={t("gitBar.mergeUpstream", { upstream: git.upstream })}
|
|
icon={<GitMerge className="h-3 w-3" />}
|
|
busy={busy === "merge"}
|
|
disabled={!!busy || dirty > 0}
|
|
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
|
|
onClick={() => merge(git.upstream as string)}
|
|
/>
|
|
)}
|
|
|
|
{/* A repo with no commit has nothing to push, fetch into or branch
|
|
from — the one next step is the commit box below. */}
|
|
{git.unborn ? (
|
|
<span className="ml-auto text-[10px] text-fg-muted">{t("gitBar.unbornHint")}</span>
|
|
) : (
|
|
<div className="ml-auto flex shrink-0 items-center">
|
|
<Btn
|
|
compact
|
|
label={t("gitBar.fetch")}
|
|
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
|
busy={busy === "fetch"}
|
|
disabled={!git.hasRemote || !!busy}
|
|
title={t("gitBar.fetchHint")}
|
|
onClick={() => run("fetch")}
|
|
/>
|
|
<Btn
|
|
compact
|
|
label={t("gitBar.pull")}
|
|
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
|
busy={busy === "pull"}
|
|
disabled={!git.hasRemote || !!busy || merging}
|
|
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
|
|
onClick={() => run("pull")}
|
|
/>
|
|
<Btn
|
|
compact
|
|
// Same `push -u <remote> HEAD` either way; a branch with no
|
|
// upstream is being published, not updated, so it says that.
|
|
label={git.hasRemote && !git.upstream ? t("gitBar.publishBranch") : t("gitBar.push")}
|
|
icon={<Upload className="h-3.5 w-3.5" />}
|
|
busy={busy === "push"}
|
|
disabled={!git.hasRemote || !!busy}
|
|
title={
|
|
git.hasRemote && !git.upstream
|
|
? t("gitBar.publishBranchHint")
|
|
: t("gitBar.pushHint")
|
|
}
|
|
onClick={() => run("push")}
|
|
/>
|
|
{!git.hasRemote && (
|
|
<Btn
|
|
compact
|
|
label={t("gitBar.publish")}
|
|
icon={<CloudUpload className="h-3.5 w-3.5" />}
|
|
disabled={!!busy}
|
|
title={t("gitBar.publishHint")}
|
|
onClick={() => {
|
|
setPublishing((v) => !v);
|
|
setError(null);
|
|
}}
|
|
/>
|
|
)}
|
|
<Btn
|
|
compact
|
|
label={t("gitBar.switch")}
|
|
icon={<GitBranch className="h-3.5 w-3.5" />}
|
|
disabled={!!busy || merging}
|
|
// A dirty tree is refused server-side; saying so before the click
|
|
// beats a red box after it.
|
|
title={
|
|
merging
|
|
? t("gitBar.merging")
|
|
: dirty > 0
|
|
? t("gitBar.switchDirty")
|
|
: t("gitBar.switchHint")
|
|
}
|
|
onClick={openSwitch}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{switching && (
|
|
<div className="flex items-center gap-1.5">
|
|
<select
|
|
aria-label={t("gitBar.switchLabel")}
|
|
value={picked}
|
|
disabled={!!busy}
|
|
onChange={(e) => setPicked(e.target.value)}
|
|
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary focus:border-accent focus:outline-none disabled:opacity-40"
|
|
>
|
|
<option value="">{t("gitBar.switchLabel")}</option>
|
|
{(branches || []).map((b) => {
|
|
const held = heldBy.get(b);
|
|
return (
|
|
<option key={b} value={b}>
|
|
{b}
|
|
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
<Btn
|
|
label={t("gitBar.switchApply")}
|
|
busy={busy === "switch"}
|
|
// A branch git already has checked out somewhere else cannot be
|
|
// switched to — git allows one worktree per branch.
|
|
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
|
|
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
|
onClick={() => void run("switch", { branch: picked })}
|
|
/>
|
|
<Btn
|
|
label={t("gitBar.merge")}
|
|
icon={<GitMerge className="h-3 w-3" />}
|
|
busy={busy === "merge"}
|
|
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
|
|
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
|
|
onClick={() => merge(picked)}
|
|
/>
|
|
{onNewLane && (
|
|
<Btn
|
|
label={t("gitBar.newLane")}
|
|
// A branch any worktree holds — this lane's own included — is
|
|
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
|
|
title={
|
|
picked && (heldBy.has(picked) || picked === git.branch)
|
|
? t("gitBar.newLaneHeld", {
|
|
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
|
|
})
|
|
: t("gitBar.newLaneHint")
|
|
}
|
|
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
|
// The sibling lane is created through the normal Add-lane flow,
|
|
// prefilled — one provisioning path, not a second one here.
|
|
onClick={() => onNewLane(picked)}
|
|
/>
|
|
)}
|
|
<Btn
|
|
label={t("gitBar.branchDelete")}
|
|
icon={<Trash2 className="h-3 w-3" />}
|
|
busy={busy === "branch-delete"}
|
|
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
|
title={t("gitBar.branchDeleteHint")}
|
|
onClick={() => void run("branch-delete", { branch: picked })}
|
|
/>
|
|
{dirty > 0 && (
|
|
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{switching && (
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
value={newBranch}
|
|
onChange={(e) => setNewBranch(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && newBranch.trim())
|
|
void run("branch-create", { branch: newBranch.trim() });
|
|
}}
|
|
placeholder={t("gitBar.branchNewPlaceholder")}
|
|
aria-label={t("gitBar.branchNewPlaceholder")}
|
|
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
|
/>
|
|
<Btn
|
|
label={t("gitBar.branchCreate")}
|
|
icon={<Plus className="h-3 w-3" />}
|
|
busy={busy === "branch-create"}
|
|
disabled={!newBranch.trim()}
|
|
// Creating does not switch: the point is usually a sibling lane,
|
|
// and moving this pane's HEAD under a working agent is not.
|
|
title={t("gitBar.branchCreateHint")}
|
|
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{publishing && !git.hasRemote && !git.unborn && (
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
|
|
}}
|
|
// The server decides which URLs are acceptable (https without a
|
|
// token, ssh, user@host:path); this only names the two usual ones.
|
|
placeholder={t("gitBar.publishUrl")}
|
|
aria-label={t("gitBar.publishUrl")}
|
|
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
|
/>
|
|
<Btn
|
|
label={t("gitBar.publishApply")}
|
|
icon={<Upload className="h-3 w-3" />}
|
|
busy={busy === "publish"}
|
|
disabled={!url.trim()}
|
|
title={t("gitBar.publishHint")}
|
|
onClick={() => void run("publish", { url: url.trim() })}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{login && (
|
|
<form
|
|
data-testid="lane-git-login"
|
|
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const { protocol, host } = login.auth;
|
|
void run(login.action, {
|
|
credentials: { protocol, host, username: username.trim(), password, remember },
|
|
});
|
|
}}
|
|
>
|
|
<p className="font-medium text-fg-primary">
|
|
{t("gitBar.loginTitle", { host: login.auth.host })}
|
|
</p>
|
|
{login.auth.rejected && (
|
|
<p role="alert" className="text-status-danger">
|
|
{t("gitBar.loginRejected")}
|
|
</p>
|
|
)}
|
|
<div className="flex gap-1.5">
|
|
<input
|
|
aria-label={t("gitBar.loginUser")}
|
|
placeholder={t("gitBar.loginUser")}
|
|
autoComplete="username"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
|
/>
|
|
<input
|
|
type="password"
|
|
aria-label={t("gitBar.loginPassword")}
|
|
placeholder={t("gitBar.loginPassword")}
|
|
autoComplete="current-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
|
/>
|
|
</div>
|
|
<label className="flex items-center gap-1.5 text-fg-secondary">
|
|
<input
|
|
type="checkbox"
|
|
checked={remember}
|
|
onChange={(e) => setRemember(e.target.checked)}
|
|
/>
|
|
{t("gitBar.loginRemember")}
|
|
</label>
|
|
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
|
|
{/* The token crosses the network in the clear on a plain-http LAN address. */}
|
|
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
|
|
<p className="text-[10px] text-status-warning">{t("gitBar.loginInsecure")}</p>
|
|
)}
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
type="submit"
|
|
disabled={!username.trim() || !password || !!busy}
|
|
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
{t("gitBar.loginSubmit")}
|
|
</button>
|
|
<Btn
|
|
label={t("gitBar.loginCancel")}
|
|
onClick={() => {
|
|
setLogin(null);
|
|
setPassword("");
|
|
}}
|
|
/>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{error && (
|
|
<pre
|
|
role="alert"
|
|
data-testid="lane-git-bar-error"
|
|
className="max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger"
|
|
>
|
|
{error}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default LaneGitBar;
|