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:
@@ -5,7 +5,8 @@
|
||||
* for the built-in Swagger and ReDoc surfaces, plus the read-only
|
||||
* `LaneStageDetectionFields` schema for the stage-detection fields every lane
|
||||
* response carries, and the idempotent `POST /api/lanes/ensure` lookup the
|
||||
* Workspace page opens a directory with.
|
||||
* Workspace page opens a directory with, and the Files tab's reads (tree,
|
||||
* quick-open, download — also in a folder that is not a git repo) and upload.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -688,7 +689,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "Tracked paths matching a substring (quick-open)",
|
||||
description:
|
||||
"Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. Read-only, no same-origin guard.",
|
||||
"Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. In a folder that is not a git repo it walks the disk instead (realpath-confined, no symlink followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, no same-origin guard.",
|
||||
operationId: "listLanePaths",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
@@ -705,7 +706,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "One directory level of tracked files",
|
||||
description:
|
||||
"The file explorer's expand-a-folder read: tracked entries under path=, folded into directories at that level. Read-only, no same-origin guard.",
|
||||
"The file explorer's expand-a-folder read: tracked entries under path=, folded into directories at that level. In a folder that is not a git repo the level is read off the disk instead (no status; `.git` hidden, `node_modules` listed as ignored and never entered). Read-only, no same-origin guard.",
|
||||
operationId: "listLaneTree",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
@@ -723,7 +724,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "Every level of the tracked tree in one read",
|
||||
description:
|
||||
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries. Read-only, no same-origin guard.",
|
||||
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries; a folder that is not a git repo is walked off the disk instead (at most 20 000 entries, then truncated). Read-only, no same-origin guard.",
|
||||
operationId: "listLaneTreeAll",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
@@ -732,6 +733,66 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/download": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Download one working file of the workspace",
|
||||
description:
|
||||
"The Files tab's Download: one working-copy file of any type, also in a folder that is not a git repo (server/lib/lane-file-transfer.js). Same guards as the /git/file read (lexical lane path, realpath confinement, secret refusal; under S3 a member's file is opened through openSafe), at most 512 MB. Served as an `application/octet-stream` attachment (an ASCII filename plus `filename*=UTF-8''…`) with `Content-Security-Policy: default-src 'none'; sandbox`, `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`, so nothing in it renders or runs on the dashboard's origin. Honours `?session=`/`?source=` like the other /git reads. Read-only, no same-origin guard.",
|
||||
operationId: "downloadLaneFile",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "The file's bytes, as an attachment." },
|
||||
400: { description: "EBADPATH, ENOFILE or ETOOBIG." },
|
||||
403: {
|
||||
description:
|
||||
"ESECRET, or EFOREIGN (S3: the workspace folder resolves into another account's files).",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/upload": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Upload one new file into a folder of the workspace",
|
||||
description:
|
||||
"The Files tab's Upload. The body is the file's raw bytes, its name the URI-encoded `X-File-Name` header, `dir` the workspace-relative folder (\"\" = the root). It only CREATES: a taken name (a symlink at it included) is 409 EEXISTS, never an overwrite. Writes into an existing folder only, never into or under `.git`, never a secret name (`settings.local.json`, a member's home dot folder), at most 25 MB. Under S3 the file is written as the folder's member through `write-as`; elsewhere with an exclusive create. Works in a folder that is not a git repo. Same-origin guarded.",
|
||||
operationId: "uploadLaneFile",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
|
||||
{
|
||||
name: "X-File-Name",
|
||||
in: "header",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "The new file's name, URI-encoded: one plain segment of at most 255 bytes.",
|
||||
},
|
||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/octet-stream": { schema: { type: "string", format: "binary" } },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: { description: "{path} — the workspace-relative path written." },
|
||||
400: { description: "EBADPATH (.git), EBADNAME, ENOFILE (no such folder) or EBADCONTENT." },
|
||||
403: { description: "ESECRET, EFOREIGN, or EBADORIGIN (cross-origin)." },
|
||||
404: { description: "Lane not found." },
|
||||
409: { description: "EEXISTS — a file of that name is already there." },
|
||||
413: { description: "ETOOBIG — over 25 MB." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/branches": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
|
||||
@@ -170,6 +170,14 @@ const paths = {
|
||||
"Inclusive upper bound on `started_at` (ISO 8601). An unparseable value is ignored. Applied before `limit`/`offset`.",
|
||||
example: "2026-10-05T23:59:59.999Z",
|
||||
},
|
||||
{
|
||||
name: "pinned_first",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description:
|
||||
"`1` = pinned sessions first (by `pinned_at`, oldest pin first), then the normal order. Applied before `limit`/`offset`, so a pinned old session stays on the first page. Omitted = order unchanged.",
|
||||
example: "1",
|
||||
},
|
||||
{
|
||||
$ref: "#/components/parameters/SourcesQuery",
|
||||
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
|
||||
@@ -300,7 +308,7 @@ const paths = {
|
||||
tags: ["Sessions"],
|
||||
summary: "Update session",
|
||||
description:
|
||||
"Partially updates a session by `id`. Only `name`, `status`, `ended_at`, and `metadata` are accepted; any field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist.",
|
||||
"Partially updates a session by `id`. `name`, `status`, `ended_at`, `metadata`, `archived` and `pinned` are accepted (`archived`/`pinned` must be booleans, else 400 `INVALID_INPUT`; they set or clear `archived_at`/`pinned_at`, and a pin-only PATCH leaves `updated_at` unchanged); any other field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist.",
|
||||
operationId: "updateSession",
|
||||
parameters: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user