feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the Artifact tool's hook), pinned workspace files, skills and plugins, each kept as a snapshot; shared ones appear in a Hub and a skill or plugin installs into the viewer's own account (server/lib/artifacts.js, /api/artifacts, docs/plans/artifacts/). - Workspace extra folders: clone one or more related repos beside the workspace and add them in one step (POST /api/lanes/:id/sources/clone). - A pane that ended by itself while tmux kept running is no longer offered for resume (tmux.serverStartedAt in reconcileOrphans). - Changes panel: a folder that is not a git repo opens on Files, and its files open from there. - Plus the parallel sessions' work in this tree: File Explorer button in the panel header, user delete, RSA SSH keys, chat/terminal and pet fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file CloneSourcesForm — a workspace's "Or clone repositories" box, under
|
||||
* its Folders card: one URL per line, each cloned BESIDE the workspace folder
|
||||
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
||||
* added as an extra folder, one after another. A host that wants a login
|
||||
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
||||
* URLs with it, like Add workspace → Clone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type LaneSourcesView } from "../../lib/api";
|
||||
|
||||
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
export function CloneSourcesForm({
|
||||
laneId,
|
||||
parent,
|
||||
onAdded,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** Where the clones land: the workspace folder's parent, shown only. */
|
||||
parent: string;
|
||||
onAdded: (view: LaneSourcesView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [text, setText] = useState("");
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
|
||||
const urls = (s: string) => [
|
||||
...new Set(
|
||||
s
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
];
|
||||
|
||||
const cloneAll = async (list: string[]) => {
|
||||
if (busy || !list.length) return;
|
||||
setBusy(true);
|
||||
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
|
||||
const show = () => setRows([...next]);
|
||||
show();
|
||||
let asked: Login | null = null;
|
||||
for (const row of next) {
|
||||
row.state = "busy";
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
row.state = "done";
|
||||
onAdded(view);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || row.url;
|
||||
row.state = "failed";
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
row.note = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
asked ??= {
|
||||
host,
|
||||
username: login?.username ?? "",
|
||||
token: "",
|
||||
remember: login?.remember ?? true,
|
||||
};
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
row.note = t("clone.sshRefused", { host });
|
||||
} else {
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
// What is left to do stays in the box; what was added leaves it.
|
||||
setText(
|
||||
next
|
||||
.filter((r) => r.state !== "done")
|
||||
.map((r) => r.url)
|
||||
.join("\n")
|
||||
);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||
<textarea
|
||||
data-testid="workspace-clone-input"
|
||||
aria-label={t("home.cloneFolders")}
|
||||
placeholder={t("home.clonePlaceholder")}
|
||||
rows={3}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||
{login && (
|
||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
{failed.length > 0 && !busy && (
|
||||
<button type="button" onClick={() => void cloneAll(failed)} className="btn-ghost text-xs">
|
||||
{t("home.cloneRetry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-clone-run"
|
||||
disabled={busy || !urls(text).length}
|
||||
onClick={() => void cloneAll(urls(text))}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{busy ? t("clone.cloning") : t("home.cloneRun")}
|
||||
</button>
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<ul className="space-y-1" data-testid="workspace-clone-rows">
|
||||
{rows.map((r) => (
|
||||
<li key={r.url} className="text-[11px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{r.state === "busy" ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-fg-muted" />
|
||||
) : r.state === "done" ? (
|
||||
<Check
|
||||
className="h-3 w-3 shrink-0 text-status-success"
|
||||
aria-label={t("home.cloneDone")}
|
||||
/>
|
||||
) : r.state === "failed" ? (
|
||||
<X className="h-3 w-3 shrink-0 text-status-danger" />
|
||||
) : (
|
||||
<span className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-mono text-fg-secondary" title={r.url}>
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -270,9 +270,13 @@ export function FileBrowser({
|
||||
const folderInput = useRef<HTMLInputElement>(null);
|
||||
const ctx = useContextMenu<FsEntry | null>();
|
||||
|
||||
// A new caller target (another workspace's Files tab, the page's URL).
|
||||
// A new caller target (another workspace's Files tab, the page's URL) —
|
||||
// not the echo of our own `go()` through the page's URL, which would wipe
|
||||
// Back's history on every folder opened.
|
||||
useEffect(() => {
|
||||
setTarget(placeOf());
|
||||
const next = placeOf();
|
||||
if (samePlace(next, target) && initialPath === path) return;
|
||||
setTarget(next);
|
||||
setPathState(initialPath);
|
||||
setHistory({ back: [], fwd: [] });
|
||||
setSelected(new Set());
|
||||
@@ -1145,7 +1149,13 @@ export function FileBrowser({
|
||||
{listing && !entries.length && !creating && !error && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-fg-muted">
|
||||
<Folder className="h-16 w-16 opacity-40" />
|
||||
<p className="text-sm">{query ? t("fx.noMatch") : t("fx.empty")}</p>
|
||||
<p className="max-w-md text-center text-sm">
|
||||
{query
|
||||
? t("fx.noMatch")
|
||||
: isHomePlace(target) && !path
|
||||
? t("fx.homeEmpty")
|
||||
: t("fx.empty")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{loading && !listing && <p className="p-6 text-sm text-fg-muted">{t("fx.loading")}</p>}
|
||||
|
||||
@@ -102,9 +102,9 @@ import {
|
||||
Sparkles,
|
||||
Trash2,
|
||||
UnfoldVertical,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -810,8 +810,12 @@ export function LaneChangesPanel({
|
||||
wide = full,
|
||||
onDirtyChange,
|
||||
openRequest = null,
|
||||
filesChanged = 0,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** Bumped when files changed outside this panel (the header's File
|
||||
* Explorer): the tree is read again, its open folders kept open. */
|
||||
filesChanged?: number;
|
||||
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
|
||||
openRequest?: { path: string; nonce: number } | null;
|
||||
/** The whole-file editor holds unsaved text (true) or not (false). */
|
||||
@@ -855,6 +859,9 @@ export function LaneChangesPanel({
|
||||
// history and the tree too, not just the diff `load` re-reads.
|
||||
const [reread, setReread] = useState(0);
|
||||
const [range, setRange] = useState<LaneChangeRange>("work");
|
||||
// Local mode: the files are already on this machine — no upload, download
|
||||
// or File Explorer (the desktop's file manager does all three).
|
||||
const { local } = usePermissions();
|
||||
const [mode, setMode] = useState<Mode>("overview");
|
||||
const [openFile, setOpenFile] = useState<string | null>(null);
|
||||
// An image opens as a picture, never as text or a binary diff.
|
||||
@@ -870,6 +877,16 @@ export function LaneChangesPanel({
|
||||
const [mdPreview, setMdPreview] = useState(false);
|
||||
useEffect(() => setMdPreview(false), [openFile]);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||
// has no changes, review or history — only its files, which Files reads
|
||||
// from the disk. Open there instead of on a page that only says "no git".
|
||||
// Both reads must say so: the facts alone also read "unavailable" on a
|
||||
// network error or an older server.
|
||||
const notRepo = git?.available === false && data?.available === false;
|
||||
useEffect(() => {
|
||||
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
|
||||
setMode(full ? "overview" : "browse");
|
||||
}, [notRepo, mode, full]);
|
||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||||
// on first expand, and kept so re-collapsing doesn't re-fetch.
|
||||
@@ -1078,19 +1095,32 @@ export function LaneChangesPanel({
|
||||
}, [mode, laneId, query, reread, full, treeRootLoaded]);
|
||||
|
||||
// A folder's children are fetched lazily, the first time it is expanded.
|
||||
const readDir = (path: string) =>
|
||||
api.lanes
|
||||
.gitTree(target, path)
|
||||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
const toggleDir = (path: string) => {
|
||||
const opening = !expandedDirs.has(path);
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(path)) next.add(path);
|
||||
return next;
|
||||
});
|
||||
if (path in treeChildren) return;
|
||||
void api.lanes
|
||||
.gitTree(target, path)
|
||||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
// Every OPEN reads the folder again (the cached level shows meanwhile):
|
||||
// files land in it from outside the panel — an agent, the terminal, the
|
||||
// File Explorer — and a level cached while it was empty stayed empty.
|
||||
if (opening) void readDir(path);
|
||||
};
|
||||
|
||||
// Files changed outside the panel: read the root and every open folder again.
|
||||
useEffect(() => {
|
||||
if (!filesChanged) return;
|
||||
void readDir("");
|
||||
for (const p of expandedDirs) void readDir(p);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesChanged]);
|
||||
|
||||
const [treeBusy, setTreeBusy] = useState(false);
|
||||
|
||||
// "Expand all" is ONE read of the whole tree: a request per folder meant a
|
||||
@@ -1624,15 +1654,17 @@ 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>
|
||||
{!local && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-download"
|
||||
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
|
||||
>
|
||||
{t("changes.download")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{treeCtx.menu.item.dir && (
|
||||
{!local && treeCtx.menu.item.dir && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-upload"
|
||||
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
|
||||
@@ -1640,7 +1672,7 @@ export function LaneChangesPanel({
|
||||
{t("changes.uploadHere")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{treeCtx.menu.item.dir && (
|
||||
{!local && treeCtx.menu.item.dir && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-explorer"
|
||||
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
|
||||
@@ -2134,9 +2166,9 @@ 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.openExplorer")} onClick={() => setExplorer({ path: "" })}>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
{/* The File Explorer's button lives in the panel header now (beside
|
||||
Full screen); this instance serves the tree's "Open in File
|
||||
Explorer". */}
|
||||
<FileExplorer
|
||||
open={!!explorer}
|
||||
laneId={laneId}
|
||||
@@ -2265,14 +2297,16 @@ export function LaneChangesPanel({
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
{t("changes.back")}
|
||||
</button>
|
||||
) : (
|
||||
) : notRepo && full ? null : (
|
||||
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{/* Files is the whole repo's tree; at full width it is the
|
||||
sidebar already, so no button. */}
|
||||
{(full
|
||||
? (["overview", "review", "history"] as const)
|
||||
: (["overview", "browse", "review", "history"] as const)
|
||||
).map((m) => (
|
||||
{(notRepo
|
||||
? (["browse"] as const)
|
||||
: full
|
||||
? (["overview", "review", "history"] as const)
|
||||
: (["overview", "browse", "review", "history"] as const)
|
||||
).map((m: Mode) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
@@ -2627,7 +2661,9 @@ export function LaneChangesPanel({
|
||||
browsePane
|
||||
) : !data ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
) : !data.available ? (
|
||||
) : !data.available && mode !== "file" ? (
|
||||
// A file still opens from Files: its whole-file read is the disk's,
|
||||
// not git's.
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
|
||||
</p>
|
||||
@@ -2878,7 +2914,7 @@ export function LaneChangesPanel({
|
||||
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
{range !== "commit" && openFile && (
|
||||
{!local && range !== "commit" && openFile && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-download"
|
||||
@@ -2970,7 +3006,9 @@ export function LaneChangesPanel({
|
||||
collapse={!whole}
|
||||
revertLabel={t("changes.revertBlock")}
|
||||
value={draft}
|
||||
original={baseText ?? draft}
|
||||
// Outside a repo there is no HEAD: the edits are
|
||||
// shown against the text as it was opened.
|
||||
original={(notRepo ? text?.lines.join("\n") : baseText) ?? draft}
|
||||
lang={langFor(openFile ?? "")}
|
||||
label={t("changes.editorLabel", { path: openFile })}
|
||||
onChange={setDraft}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||
* workspace home's right column and, `compact`, folded at the bottom of the
|
||||
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
||||
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,6 +22,7 @@ import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { CloneSourcesForm } from "./CloneSourcesForm";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
@@ -436,6 +438,16 @@ function ContextBody({
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{folderOpen && (
|
||||
<CloneSourcesForm
|
||||
laneId={lane.id}
|
||||
parent={lane.cwd.replace(/\/+$/, "").replace(/\/[^/]*$/, "") || "/"}
|
||||
onAdded={(next) => {
|
||||
setView(next);
|
||||
onSourcesChange?.(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
||||
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||
* re-runs only the failed URLs with that login.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm } from "../CloneSourcesForm";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
|
||||
|
||||
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdded = vi.fn();
|
||||
vi.mocked(api.lanes.cloneFolder).mockImplementation(async (_id, body) => {
|
||||
if (body?.url.includes("private") && !body.credentials)
|
||||
throw Object.assign(new Error("login"), {
|
||||
code: "ECREDENTIALS",
|
||||
auth: { protocol: "https", host: "private.test" },
|
||||
});
|
||||
return view;
|
||||
});
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId("workspace-clone-input"),
|
||||
"https://h/a.git\nhttps://private.test/b.git\nhttps://h/a.git"
|
||||
);
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
||||
expect(onAdded).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
||||
// Only what is left stays in the box.
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
||||
|
||||
await user.type(screen.getByLabelText("Username"), "dev");
|
||||
await user.type(screen.getByLabelText("Password or access token"), "tok");
|
||||
await user.click(screen.getByRole("button", { name: "Retry the failed ones" }));
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,12 @@ import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
||||
// Local mode unless a test says otherwise.
|
||||
const perms = vi.hoisted(() => ({ local: true }));
|
||||
vi.mock("../../AuthGate", async (importOriginal) => {
|
||||
const m = await importOriginal<typeof import("../../AuthGate")>();
|
||||
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
|
||||
});
|
||||
vi.mock("../DiffEditor", () => ({
|
||||
default: (p: {
|
||||
value: string;
|
||||
@@ -146,6 +152,7 @@ function discardOf(path: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = true;
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -310,16 +317,29 @@ describe("LaneChangesPanel", () => {
|
||||
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
|
||||
});
|
||||
|
||||
it("says so, quietly, when the lane's directory is not a git repo", async () => {
|
||||
it("says so, quietly, beside the tree at full width, when the folder is not a repo", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||
available: false,
|
||||
range: "work",
|
||||
files: [],
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
expect(
|
||||
await screen.findByText("This workspace's directory is not a readable git repository.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens on Files, its only tab, when the workspace folder is not a repo", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||
available: false,
|
||||
range: "work",
|
||||
files: [],
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(
|
||||
await screen.findByText("This workspace's directory is not a readable git repository.")
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("lane-changes-browse")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Files" })).toHaveAttribute("aria-pressed", "true");
|
||||
for (const name of ["Changes", "Review", "History"])
|
||||
expect(screen.queryByRole("button", { name })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
|
||||
@@ -548,7 +568,38 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads a folder again on every open, so files added since show (not a stale empty level)", async () => {
|
||||
let filled = false;
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||
available: true,
|
||||
path,
|
||||
entries:
|
||||
path === "src"
|
||||
? filled
|
||||
? [{ name: "app.ts", path: "src/app.ts", dir: false }]
|
||||
: []
|
||||
: [{ name: "src", path: "src", dir: true }],
|
||||
truncated: false,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("src"));
|
||||
await waitFor(() => expect(api.lanes.gitTree).toHaveBeenCalledWith(7, "src"));
|
||||
expect(within(sidebar).queryByText("app.ts")).toBeNull();
|
||||
// Source arrives from outside the panel (the File Explorer, a terminal).
|
||||
filled = true;
|
||||
await user.click(within(sidebar).getByText("src")); // close
|
||||
await user.click(within(sidebar).getByText("src")); // open again → read again
|
||||
expect(await within(sidebar).findByText("app.ts")).toBeInTheDocument();
|
||||
// And the header's File Explorer reports a change: open folders re-read.
|
||||
filled = false;
|
||||
rerender(<LaneChangesPanel laneId={7} full filesChanged={1} />);
|
||||
await waitFor(() => expect(within(sidebar).queryByText("app.ts")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", async () => {
|
||||
perms.local = false;
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||
available: true,
|
||||
path,
|
||||
@@ -586,6 +637,25 @@ describe("LaneChangesPanel", () => {
|
||||
picked.mockRestore();
|
||||
});
|
||||
|
||||
it("offers no upload, download or File Explorer in local mode", 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,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
|
||||
expect(screen.getByTestId("lane-changes-tree-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-changes-menu-upload")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-changes-menu-explorer")).toBeNull();
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -1728,6 +1798,24 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a file from Files when the workspace folder is not a repo", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||
available: false,
|
||||
range: "work",
|
||||
files: [],
|
||||
});
|
||||
render(<Tabbed laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-browse");
|
||||
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
expect(
|
||||
screen.queryByText("This workspace's directory is not a readable git repository.")
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
Reference in New Issue
Block a user