feat: file upload/download and pinned sessions, text size, git identity and clone

Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):

- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
  /:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
  (git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
  the session menu, pinned first); text size setting (lib/fontSize.ts,
  Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
  routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().

Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 12:21:55 +07:00
parent 0cb5e41faf
commit cb19d7e5d2
63 changed files with 3833 additions and 209 deletions
+185 -7
View File
@@ -1,12 +1,15 @@
/**
* @file AddLaneModal.tsx
* @description The "+ Add lane" flow, in one of two modes chosen with a
* @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. The user also picks which CLI drives the lane
* 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). The user also picks which CLI drives the lane
* (Claude Code or Codex) — this choice is immutable after creation. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
@@ -48,6 +51,17 @@ async function waitForProvisioned(
}
}
/** 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,
@@ -97,7 +111,18 @@ export function AddLaneModal({
cwdSuggestions: CwdSuggestion[];
}) {
const { t } = useTranslation(["lanes"]);
const [mode, setMode] = useState<"repo" | "worktree">("repo");
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("");
@@ -141,6 +166,10 @@ export function AddLaneModal({
const reset = () => {
setMode("repo");
setCloneUrl("");
setCloneName("");
setLogin(null);
setSshRefused(false);
setSourceRepo("");
setTitle("");
setBranch("");
@@ -213,6 +242,52 @@ export function AddLaneModal({
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({
@@ -337,6 +412,8 @@ export function AddLaneModal({
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()))));
@@ -393,10 +470,38 @@ export function AddLaneModal({
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>
)}
<div>
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
{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")}{" "}
@@ -457,7 +562,11 @@ export function AddLaneModal({
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
{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
@@ -473,10 +582,67 @@ export function AddLaneModal({
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
{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. */}
@@ -690,6 +856,14 @@ export function AddLaneModal({
{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>
@@ -710,7 +884,11 @@ export function AddLaneModal({
data-tour="add-lane-create"
className="btn-primary text-xs disabled:opacity-50"
>
{done ? t("addLaneSetupClose") : t("add")}
{done
? t("addLaneSetupClose")
: busy && mode === "clone"
? t("clone.cloning")
: t("add")}
</button>
</div>
</div>
@@ -87,6 +87,7 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Check,
Download,
Code2,
Columns2,
Eye,
@@ -101,6 +102,7 @@ import {
Sparkles,
Trash2,
UnfoldVertical,
Upload,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
@@ -939,6 +941,13 @@ export function LaneChangesPanel({
const [message, setMessage] = useState("");
// The commit row amends the last commit instead of making a new one.
const [amend, setAmend] = useState(false);
// The last commit is already on its upstream: the server refuses to amend
// it (EPUSHED), so the box is not offered — asking first and failing after
// the confirm only costs the user a click.
const headPushed = !!git?.available && !!git.upstream && git.ahead === 0 && !!git.head;
useEffect(() => {
if (headPushed) setAmend(false);
}, [headPushed]);
// The files the commit takes (JetBrains' checkboxes). A path listed for the
// first time arrives with its default; `seenPaths` is the last full list,
// so a poll never re-ticks a file the user unticked.
@@ -1614,8 +1623,22 @@ export function LaneChangesPanel({
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
{t("changes.showHistory")}
</MenuItem>
<MenuItem
testId="lane-changes-menu-download"
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
>
{t("changes.download")}
</MenuItem>
</>
)}
{treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-upload"
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
>
{t("changes.uploadHere")}
</MenuItem>
)}
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
{t("changes.copyPath")}
</MenuItem>
@@ -1861,6 +1884,57 @@ export function LaneChangesPanel({
};
const [rereading, runReread] = useSpin(rereadAll);
// Files tab transfers (issue #78): a download is a plain attachment link; an
// upload only creates, into the folder it was asked for ("" = the root).
const uploadInput = useRef<HTMLInputElement>(null);
const uploadDir = useRef("");
const [uploading, setUploading] = useState(false);
const download = (path: string) => {
const a = document.createElement("a");
a.href = api.lanes.gitDownloadUrl(target, path);
a.download = path.split("/").pop() || path;
a.click();
};
const pickUpload = (dir: string) => {
uploadDir.current = dir;
uploadInput.current?.click();
};
const upload = async (files: File[]) => {
if (!files.length) return;
const lane = laneId;
const dir = uploadDir.current;
setUploading(true);
let saved = 0;
try {
for (const f of files) {
try {
await api.lanes.gitUpload(target, dir, f);
saved++;
} catch (err) {
toast.error(
t("changes.uploadFailed", {
name: f.name,
message: err instanceof Error ? err.message : String(err),
})
);
}
}
} finally {
if (laneNow.current === lane) {
setUploading(false);
if (saved) {
toast.success(t("changes.uploaded", { count: saved, dir: dir || "/" }));
// Re-read the level it landed in, and open it.
void api.lanes
.gitTree(target, dir)
.then(forLane(lane, (r) => setTreeChildren((prev) => ({ ...prev, [dir]: r.entries }))))
.catch(() => undefined);
if (dir) setExpandedDirs((prev) => new Set(prev).add(dir));
}
}
}
};
const toggleCollapsed = (path: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
@@ -2047,6 +2121,21 @@ export function LaneChangesPanel({
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<IconButton label={t("changes.upload")} disabled={uploading} onClick={() => pickUpload("")}>
<Upload className="h-3.5 w-3.5" />
</IconButton>
<input
ref={uploadInput}
type="file"
multiple
hidden
data-testid="lane-changes-upload-input"
onChange={(e) => {
const files = [...(e.target.files ?? [])];
e.target.value = "";
void upload(files);
}}
/>
{!hits && (
<IconButton
label={
@@ -2566,13 +2655,16 @@ export function LaneChangesPanel({
</div>
<div className="flex flex-wrap items-center gap-1.5">
<label
title={t("changes.amendHint")}
className="flex shrink-0 cursor-pointer items-center gap-1 text-[11px] text-fg-secondary"
title={headPushed ? t("changes.amendPushed") : t("changes.amendHint")}
className={`flex shrink-0 items-center gap-1 text-[11px] text-fg-secondary ${
headPushed ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
>
<input
type="checkbox"
data-testid="lane-changes-amend"
checked={amend}
disabled={headPushed}
onChange={(e) => setAmend(e.target.checked)}
/>
{t("changes.amend")}
@@ -2758,6 +2850,18 @@ export function LaneChangesPanel({
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{range !== "commit" && openFile && (
<button
type="button"
data-testid="lane-file-download"
onClick={() => download(openFile)}
title={t("changes.download")}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
<Download className="h-3 w-3" />
{t("changes.download")}
</button>
)}
{/* Only the working file is editable: a commit is history. */}
{/* A working file opens in the editor; the read-only diff
(Whole file off) keeps an Edit button back into it. */}
+18 -3
View File
@@ -242,13 +242,27 @@ export function LaneGitBar({
void run(action, body);
}
};
const push = () =>
confirmRun(
const push = async () => {
let ahead = git.ahead ?? 0;
if (git.upstream && ahead === 0) {
// The facts can be up to 30 s old (an agent may have just committed):
// ask git again before saying there is nothing to push — and never
// ask to confirm pushing zero commits.
const read = await api.lanes.git(gitTarget(laneId, session, source)).catch(() => null);
const fresh = read?.available ? read : null;
ahead = fresh?.ahead ?? 0;
if (fresh?.upstream && ahead === 0) {
toast.info(t("gitBar.nothingToPush", { upstream: fresh.upstream }));
onChanged();
return;
}
}
return confirmRun(
git.upstream
? {
title: t("gitBar.push"),
message: t("gitBar.pushConfirm", {
count: git.ahead ?? 0,
count: ahead,
branch: git.branch,
upstream: git.upstream,
}),
@@ -259,6 +273,7 @@ export function LaneGitBar({
},
"push"
);
};
const publish = () =>
confirmRun(
{
+9 -1
View File
@@ -4,7 +4,7 @@
* the sidebar's **New session** row). Every item is something the row already
* does: open it the way its click does (a live pane attaches, an ended session
* opens its conversation — never a resume), open a live one in a split,
* rename / archive / unarchive when the list offers them, copy its id, open
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -29,6 +29,7 @@ export function SessionMenu({
onRename,
onArchive,
onUnarchive,
onPin,
}: MenuPoint & {
laneId: number;
session: Session | null;
@@ -38,6 +39,8 @@ export function SessionMenu({
onRename?: () => void;
onArchive?: () => void;
onUnarchive?: () => void;
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
onPin?: () => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -84,6 +87,11 @@ export function SessionMenu({
</MenuItem>
)}
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
{onPin && session && (
<MenuItem onSelect={pick(onPin)}>
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
</MenuItem>
)}
{session && (
<>
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
@@ -1,12 +1,13 @@
/**
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
* sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
* pane above the ended ones) — never the other
* (`GET /api/sessions?lane=<id>&archived=0&pinned_first=1`, newest first —
* pinned ones on top in pin order, then the ones with a live pane, then the
* ended ones; a pin is `PATCH {pinned}`, issue #80) — never the other
* sessions that ran in its folder. A row's dot
* shows its state (`sessionDot`: live pane, needs you, error) and its right
* edge the AI tool the session ran on (`sessions.tool`); double-click
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
* renames it, its hover buttons pin/unpin and archive it (`PATCH {name}` / `{archived}`,
* which deletes nothing); a right-click opens the same actions and more
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
* carrying its session id) opens that tmux pane on the Workspace page
@@ -33,7 +34,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
@@ -278,17 +279,21 @@ export function WorkspaceSessionList({
3000
);
const refreshKey = `${hookKey}:${version}`;
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const own = useSessionPage(
{ lane: lane.id, archived: false, pinnedFirst: true, limit },
refreshKey
);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
const q = query.trim().toLowerCase();
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
// Live sessions first, ended ones under them — each group stays newest
// first (the sort is stable over the server's order).
// Pinned sessions first, then live ones, then ended ones — each group in
// the server's order (pin order, else newest first; the sort is stable).
const rank = (s: Session) => (s.pinned_at ? 0 : runFor(s) ? 1 : 2);
const shown = (sessions ?? [])
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
.sort((a, b) => rank(a) - rank(b));
// A live tab whose session has no row yet: shown as a New session at the
// top, like Claude Desktop, until its first hook brings the real row. Not
// during a search — it has no name to match.
@@ -336,7 +341,10 @@ export function WorkspaceSessionList({
if (count !== null) onMatches?.(count);
}, [count, onMatches]);
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
const patch = async (
s: Session,
body: { name?: string; archived?: boolean; pinned?: boolean }
) => {
setError(null);
try {
await api.sessions.patch(s.id, body);
@@ -533,6 +541,13 @@ export function WorkspaceSessionList({
/>
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
{s.pinned_at && (
<Pin
data-testid={`workspace-session-pinned-${s.id}`}
aria-label={t("tree.pinned")}
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
{sessionWorktreeName(lane, s) && (
<GitBranch
data-testid={`workspace-session-worktree-${s.id}`}
@@ -540,7 +555,9 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
<span
className={`ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted ${readOnly ? "" : "group-hover:invisible"}`}
>
{timeAgo(s.last_activity ?? s.started_at, true)}
</span>
<AiToolIcon
@@ -548,6 +565,22 @@ export function WorkspaceSessionList({
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
/>
</button>
{!readOnly && (
<button
type="button"
data-testid={`workspace-session-pin-${s.id}`}
onClick={() => void patch(s, { pinned: !s.pinned_at })}
title={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
aria-label={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
className="absolute right-6 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
{s.pinned_at ? (
<PinOff className="h-3.5 w-3.5" />
) : (
<Pin className="h-3.5 w-3.5" />
)}
</button>
)}
{!readOnly && (
<button
type="button"
@@ -608,6 +641,9 @@ export function WorkspaceSessionList({
onClose={sessionCtx.close}
onRename={() => startRename(sessionCtx.menu!.item)}
onArchive={() => void archive(sessionCtx.menu!.item)}
onPin={() =>
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
}
/>
)}
</>
@@ -25,6 +25,7 @@ vi.mock("../../../lib/api", () => ({
pipelines: vi.fn(),
worktree: vi.fn(),
ensure: vi.fn(),
clone: vi.fn(),
browse: vi.fn(),
pickPath: vi.fn(),
// The folder browser's Places list the user's workspaces.
@@ -156,6 +157,7 @@ beforeEach(() => {
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.clone).mockReset();
vi.mocked(api.lanes.browse).mockReset();
vi.mocked(api.lanes.pickPath).mockReset();
// Provisioning finishes instantly by default - tests that care about the
@@ -693,7 +695,7 @@ describe("AddLaneModal — tool selection", () => {
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(picker).toHaveAttribute("aria-expanded", "false");
await user.click(picker);
const list = screen.getByRole("group", { name: /Which CLI drives this workspace/ });
const list = screen.getByRole("group", { name: "Default AI tool" });
expect(
within(list)
.getAllByRole("button")
@@ -779,3 +781,76 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
);
});
});
describe("AddLaneModal — Clone", () => {
async function fillClone(user: ReturnType<typeof userEvent.setup>, url: string) {
renderModal();
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
const urlField = screen.getByLabelText("Repository URL");
await focusField(user, urlField);
await user.type(urlField, url);
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
}
it("clones into <folder>/<repo name> and reports the new workspace", async () => {
const onAdded = vi.fn();
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 5 }),
created: true,
path: "/home/an/code/repo",
});
const user = userEvent.setup();
renderModal({ onAdded });
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
const urlField = screen.getByLabelText("Repository URL");
await focusField(user, urlField);
await user.type(urlField, "git@github.com:owner/repo.git");
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
expect(screen.getByText(/cloned to \/home\/an\/code\/repo/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(
expect.objectContaining({ url: "git@github.com:owner/repo.git", parent: "/home/an/code" })
)
);
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
Object.assign(new Error("needs a login"), {
code: "ECREDENTIALS",
auth: { protocol: "https", host: "git.example.com" },
})
)
.mockResolvedValueOnce({ lane: laneFixture({ id: 6 }), created: true, path: "/x" });
const user = userEvent.setup();
await fillClone(user, "https://git.example.com/o/r.git");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
const login = await screen.findByTestId("add-lane-clone-login");
expect(screen.getByRole("alert")).toHaveTextContent("git.example.com needs a login");
await user.type(within(login).getByLabelText("Username"), "an");
await user.type(within(login).getByLabelText("Password or access token"), "tok");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(vi.mocked(api.lanes.clone).mock.calls[1]?.[0].credentials).toEqual({
username: "an",
password: "tok",
remember: true,
})
);
});
it("points to Settings → Git when the SSH key is refused", async () => {
vi.mocked(api.lanes.clone).mockRejectedValue(
Object.assign(new Error("refused"), { code: "ESSHAUTH" })
);
const user = userEvent.setup();
await fillClone(user, "git@github.com:o/r.git");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
const link = await screen.findByRole("link", { name: "Open Settings → Git" });
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
});
});
@@ -15,7 +15,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import { render, screen, waitFor, act, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
@@ -75,6 +75,9 @@ vi.mock("../../../lib/api", () => ({
gitTree: vi.fn(),
gitTreeAll: vi.fn(),
gitBlame: vi.fn(),
gitDownloadUrl: (id: number, path: string) =>
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
gitUpload: vi.fn(),
},
},
}));
@@ -545,6 +548,44 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", async () => {
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
available: true,
path,
entries:
path === "out"
? [{ name: "report.md", path: "out/report.md", dir: false }]
: [{ name: "out", path: "out", dir: true }],
truncated: false,
}));
vi.mocked(api.lanes.gitUpload).mockResolvedValue({ path: "out/data.csv" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
const input = within(sidebar).getByTestId("lane-changes-upload-input") as HTMLInputElement;
const picked = vi.spyOn(input, "click").mockImplementation(() => {});
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
await user.click(screen.getByTestId("lane-changes-menu-upload"));
expect(picked).toHaveBeenCalled();
const file = new File(["a,b\n"], "data.csv", { type: "text/csv" });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(api.lanes.gitUpload).toHaveBeenCalledWith(7, "out", file));
// The folder it landed in is re-read and opened.
expect(await within(sidebar).findByText("report.md")).toBeInTheDocument();
const clicked: string[] = [];
const anchor = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement
) {
clicked.push(this.getAttribute("href") ?? "");
});
await user.pointer({ keys: "[MouseRight]", target: within(sidebar).getByText("report.md") });
await user.click(screen.getByTestId("lane-changes-menu-download"));
expect(clicked).toEqual(["/api/lanes/7/git/download?path=out%2Freport.md"]);
anchor.mockRestore();
picked.mockRestore();
});
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
@@ -21,7 +21,7 @@ import type { LaneGitFacts } from "../../../lib/types";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn(), git: vi.fn() } },
}));
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
@@ -164,6 +164,29 @@ describe("LaneGitBar", () => {
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("says there is nothing to push instead of asking to push zero commits", async () => {
const user = userEvent.setup();
vi.mocked(confirmDialog).mockClear();
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 0 }));
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Push$/ }));
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("re-reads stale facts: a commit made since still gets the push confirm", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 1 }));
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Push$/ }));
await waitFor(() =>
expect(confirmDialog).toHaveBeenLastCalledWith(
expect.objectContaining({ message: expect.stringContaining("Push 1 commit") })
)
);
});
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
const user = userEvent.setup();
render(