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:
2026-10-14 16:04:03 +07:00
parent e94038481e
commit 22de5009ff
79 changed files with 4617 additions and 231 deletions
@@ -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>
);
}
+13 -3
View File
@@ -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} />);