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
@@ -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. */}