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:
+138
-6
@@ -21,6 +21,12 @@
|
||||
* every path in and out is cwd-relative, status entries outside the cwd are
|
||||
* dropped, and every git call treats a path as a literal file name, never a
|
||||
* pathspec glob (`GIT_LITERAL_PATHSPECS`).
|
||||
*
|
||||
* The file explorer also serves a workspace folder that is NOT a git repo
|
||||
* (issue #78): `listTree`, `listTreeAll` and `listPaths` then read the disk
|
||||
* (`walkFolder` — confined to the folder's real path, never following a
|
||||
* symlink, capped) instead of `git ls-files`, and the file reads above never
|
||||
* needed git for the working copy.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -911,7 +917,11 @@ function contentSha(buf) {
|
||||
* skip-list has to be maintained here.
|
||||
*/
|
||||
async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
|
||||
const { all, cut } = await lsFiles(dir, []);
|
||||
const { all, cut } = await lsFiles(dir, []).catch(async (err) => {
|
||||
if (await inRepo(dir)) throw err;
|
||||
const walk = await walkFolder(dir);
|
||||
return { all: walk.files, cut: walk.cut };
|
||||
});
|
||||
const needle = String(q || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
@@ -940,6 +950,87 @@ async function lsFiles(dir, args) {
|
||||
return { all: [...new Set(all.filter(Boolean))], cut };
|
||||
}
|
||||
|
||||
/** Whether `dir` is inside a git work tree (false for a plain folder). */
|
||||
async function inRepo(dir) {
|
||||
try {
|
||||
return (await git(dir, ["rev-parse", "--is-inside-work-tree"])).stdout.trim() === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Most entries one `walkFolder` visits before it stops and reports `cut`. */
|
||||
const MAX_WALK = 20_000;
|
||||
/** Folders a walk lists but never enters (a repo's tree shows them as ignored). */
|
||||
const WALK_SHUT = new Set(["node_modules"]);
|
||||
|
||||
/**
|
||||
* A plain folder's files and folders under `sub`, read off the disk, for a
|
||||
* workspace that is not a git repo: `depth` levels down (1 = `sub`'s own
|
||||
* entries), `.git` never listed, a `WALK_SHUT` folder listed but not entered,
|
||||
* a symlink listed as a file and never followed, the start confined to the
|
||||
* workspace's real path like `addIgnored`. Paths are workspace-relative.
|
||||
*/
|
||||
async function walkFolder(dir, sub = "", depth = Infinity) {
|
||||
const root = await laneRoot(dir);
|
||||
const start = await fs.promises.realpath(path.join(root, sub));
|
||||
const r = path.relative(root, start);
|
||||
if (r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r)) {
|
||||
throw badRequest("EBADPATH", "path escapes the workspace");
|
||||
}
|
||||
const files = [];
|
||||
const dirs = [];
|
||||
const shut = new Set();
|
||||
let seen = 0;
|
||||
let cut = false;
|
||||
const queue = [[sub, 1]];
|
||||
while (queue.length && !cut) {
|
||||
const [rel, level] = queue.shift();
|
||||
let dirents;
|
||||
try {
|
||||
dirents = await fs.promises.readdir(path.join(root, rel), { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const d of dirents) {
|
||||
if (d.name === ".git") continue;
|
||||
if (++seen > MAX_WALK) {
|
||||
cut = true;
|
||||
break;
|
||||
}
|
||||
const p = rel ? `${rel}/${d.name}` : d.name;
|
||||
if (!d.isDirectory()) files.push(p);
|
||||
else {
|
||||
dirs.push(p);
|
||||
if (WALK_SHUT.has(d.name)) shut.add(p);
|
||||
else if (level < depth) queue.push([p, level + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { files, dirs, shut, cut };
|
||||
}
|
||||
|
||||
/** One explorer level of a plain folder (`listTree` off the disk). */
|
||||
async function folderLevel(dir, sub) {
|
||||
const { files, dirs, shut, cut } = await walkFolder(dir, sub, 1);
|
||||
const prefix = sub ? `${sub}/` : "";
|
||||
const name = (p) => p.slice(prefix.length);
|
||||
const entries = [
|
||||
...dirs
|
||||
.map((p) => ({
|
||||
name: name(p),
|
||||
path: p,
|
||||
dir: true,
|
||||
...(shut.has(p) ? { ignored: true } : {}),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
...files
|
||||
.map((p) => ({ name: name(p), path: p, dir: false }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
];
|
||||
return { entries: entries.slice(0, MAX_PATHS), truncated: cut || entries.length > MAX_PATHS };
|
||||
}
|
||||
|
||||
/** What the explorer lists: tracked files plus untracked ones git does not
|
||||
* ignore — the new file an agent just wrote is the one you want to open. */
|
||||
const TREE_LS = ["--cached", "--others", "--exclude-standard"];
|
||||
@@ -1026,10 +1117,15 @@ async function listTree(dir, { subdir = "" } = {}) {
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.trim();
|
||||
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the workspace");
|
||||
const [{ all, cut }, st] = await Promise.all([
|
||||
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS),
|
||||
treeStatus(dir),
|
||||
]);
|
||||
let listed;
|
||||
try {
|
||||
listed = await lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS);
|
||||
} catch (err) {
|
||||
if (await inRepo(dir)) throw err;
|
||||
return { path: clean, ...(await folderLevel(dir, clean)) };
|
||||
}
|
||||
const { all, cut } = listed;
|
||||
const st = await treeStatus(dir);
|
||||
const prefix = clean ? `${clean}/` : "";
|
||||
const dirs = new Set();
|
||||
const files = [];
|
||||
@@ -1171,7 +1267,15 @@ function onlyBlock(hunk, block, reverse) {
|
||||
* per folder, which on a large repo is thousands of git processes.
|
||||
*/
|
||||
async function listTreeAll(dir) {
|
||||
const [{ all, cut }, st] = await Promise.all([lsFiles(dir, TREE_LS), treeStatus(dir)]);
|
||||
let listed;
|
||||
try {
|
||||
listed = await lsFiles(dir, TREE_LS);
|
||||
} catch (err) {
|
||||
if (await inRepo(dir)) throw err;
|
||||
return plainTreeAll(dir);
|
||||
}
|
||||
const { all, cut } = listed;
|
||||
const st = await treeStatus(dir);
|
||||
const byDir = new Map();
|
||||
const level = (d) => {
|
||||
let l = byDir.get(d);
|
||||
@@ -1203,12 +1307,40 @@ async function listTreeAll(dir) {
|
||||
return { levels, truncated };
|
||||
}
|
||||
|
||||
/** `listTreeAll` for a plain folder: one capped walk, every level from it. */
|
||||
async function plainTreeAll(dir) {
|
||||
const { files, dirs, shut, cut } = await walkFolder(dir);
|
||||
const levels = { "": [] };
|
||||
const parentOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
|
||||
const nameOf = (p) => p.slice(p.lastIndexOf("/") + 1);
|
||||
// A shut folder (node_modules) gets no level of its own: it stays closed.
|
||||
for (const p of dirs) if (!shut.has(p)) levels[p] = [];
|
||||
for (const p of dirs) {
|
||||
levels[parentOf(p)]?.push({
|
||||
name: nameOf(p),
|
||||
path: p,
|
||||
dir: true,
|
||||
...(shut.has(p) ? { ignored: true } : {}),
|
||||
});
|
||||
}
|
||||
for (const p of files) levels[parentOf(p)]?.push({ name: nameOf(p), path: p, dir: false });
|
||||
let truncated = cut;
|
||||
for (const [d, entries] of Object.entries(levels)) {
|
||||
entries.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name));
|
||||
if (entries.length > MAX_PATHS) truncated = true;
|
||||
levels[d] = entries.slice(0, MAX_PATHS);
|
||||
}
|
||||
return { levels, truncated };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectChanges,
|
||||
CONFLICT_CODES,
|
||||
contentSha,
|
||||
inRepo,
|
||||
isSecretOnDisk,
|
||||
lanePath,
|
||||
laneRoot,
|
||||
looksBinary,
|
||||
resolveInLane,
|
||||
MAX_FILE_VIEW_BYTES,
|
||||
|
||||
Reference in New Issue
Block a user