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:
+77
-1
@@ -987,6 +987,8 @@ export const api = {
|
||||
lane?: number;
|
||||
/** `false` = not archived, `true` = archived only; omitted = both. */
|
||||
archived?: boolean;
|
||||
/** Pinned sessions first (by pin time), before the limit. */
|
||||
pinnedFirst?: boolean;
|
||||
/** Only active sessions waiting on the user (`awaiting=1`). */
|
||||
awaiting?: boolean;
|
||||
/** ISO bounds on `started_at`. */
|
||||
@@ -1005,6 +1007,7 @@ export const api = {
|
||||
if (params?.tool) qs.set("tool", params.tool);
|
||||
if (params?.lane !== undefined) qs.set("lane", String(params.lane));
|
||||
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
|
||||
if (params?.pinnedFirst) qs.set("pinned_first", "1");
|
||||
if (params?.awaiting) qs.set("awaiting", "1");
|
||||
if (params?.from) qs.set("from", params.from);
|
||||
if (params?.to) qs.set("to", params.to);
|
||||
@@ -1066,7 +1069,7 @@ export const api = {
|
||||
),
|
||||
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||
* workspace, deletes nothing). */
|
||||
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||
patch: (id: string, body: { name?: string; archived?: boolean; pinned?: boolean }) =>
|
||||
request<{ session: Session }>(`/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
@@ -2440,6 +2443,24 @@ export const api = {
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
|
||||
gitIdentity: {
|
||||
get: () => request<GitIdentity>("/me/git"),
|
||||
/** Make the SSH key; `replace` swaps an existing one for a new pair. */
|
||||
generateKey: (replace = false) =>
|
||||
request<GitIdentity>("/me/git/ssh-key", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
}),
|
||||
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
|
||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
||||
removeHttps: (host: string) =>
|
||||
request<GitIdentity>(`/me/git/https/${encodeURIComponent(host)}`, { method: "DELETE" }),
|
||||
/** PUT /api/me/git/author — `user.name` / `user.email` for the account's commits. */
|
||||
saveAuthor: (body: { name: string; email: string }) =>
|
||||
request<GitIdentity>("/me/git/author", { method: "PUT", body: JSON.stringify(body) }),
|
||||
},
|
||||
/** Frozen session copies (`/api/snapshots`, `server/routes/snapshots.js`). */
|
||||
snapshots: {
|
||||
list: () => request<{ snapshots: SessionSnapshotInfo[] }>("/snapshots"),
|
||||
@@ -3046,6 +3067,25 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* POST /api/lanes/clone — `git clone <url>` into `<parent>/<name>` as the
|
||||
* folder's account, with that account's own SSH key / HTTPS login, then a
|
||||
* workspace on it. 409 `ECREDENTIALS` (the error's `auth` names the host)
|
||||
* asks for a login, sent again as `credentials`; 409 `ESSHAUTH` means the
|
||||
* SSH key was refused.
|
||||
*/
|
||||
clone: (body: {
|
||||
url: string;
|
||||
parent: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
tool?: LaneTool;
|
||||
credentials?: { username: string; password: string; remember?: boolean };
|
||||
}) =>
|
||||
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/pipelines — every pipeline template the server can render
|
||||
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
||||
@@ -3221,6 +3261,24 @@ export const api = {
|
||||
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
|
||||
gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
|
||||
`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`,
|
||||
/** GET /api/lanes/:id/git/download — one working file as an attachment
|
||||
* (any type; a plain, non-git folder too). For an `<a download>`. */
|
||||
gitDownloadUrl: (id: GitTarget, path: string) =>
|
||||
`${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`,
|
||||
/**
|
||||
* POST /api/lanes/:id/git/upload — create `file` in the folder `dir`
|
||||
* ("" = the workspace root). Only creates: a taken name answers 409
|
||||
* EEXISTS; at most 25 MB (413 ETOOBIG).
|
||||
*/
|
||||
gitUpload: (id: GitTarget, dir: string, file: File) =>
|
||||
request<{ path: string }>(gitUrl(id, "/upload", new URLSearchParams({ dir })), {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-File-Name": encodeURIComponent(file.name),
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* PUT /api/lanes/:id/git/file — save the editor's text over the working
|
||||
* file. `baseSha` is the `sha` it was opened at; a file that changed on
|
||||
@@ -3719,6 +3777,24 @@ export interface GitCredential {
|
||||
hasToken: true;
|
||||
}
|
||||
|
||||
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
|
||||
export interface GitIdentity {
|
||||
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
|
||||
account: "member" | "host";
|
||||
/** False for the host's identity seen by a non-admin. */
|
||||
writable: boolean;
|
||||
ssh: { publicKey: string; fingerprint: string } | null;
|
||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||
author: { name: string | null; email: string | null };
|
||||
https: {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
username: string;
|
||||
savedAt?: string;
|
||||
hasToken: true;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface GitCredentialInput {
|
||||
protocol: string;
|
||||
host: string;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file fontSize — the two text sizes Settings → Appearance offers (issue #79),
|
||||
* viewer preferences kept in `localStorage` and synced to the account like the
|
||||
* theme (`lib/userPrefs.ts` PREF_KEYS):
|
||||
* - `agenthub.ui.fontSize`: the interface's size, the ROOT font size in px.
|
||||
* Tailwind's sizes and spacing are rem, and `index.css` re-states every
|
||||
* `text-[Npx]` class the app uses in rem, so the whole UI follows it.
|
||||
* `index.html` applies it before first paint (same key, same range).
|
||||
* Capped at 18px: the diff view pins its rows at `ROW_PX` (16px).
|
||||
* - `agenthub.terminal.fontSize`: xterm's cell font size in px (default 13).
|
||||
* A write fires `FONT_SIZE_EVENT`, which open terminals listen for to re-fit.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const UI_FONT_SIZE_KEY = "agenthub.ui.fontSize";
|
||||
export const TERMINAL_FONT_SIZE_KEY = "agenthub.terminal.fontSize";
|
||||
/** Fired on `window` after either size changes (this tab or a hydrate). */
|
||||
export const FONT_SIZE_EVENT = "agenthub-font-size";
|
||||
|
||||
/** The interface sizes, root px; 16 is the browser's own default. */
|
||||
export const UI_FONT_SIZES = [14, 16, 17, 18] as const;
|
||||
export type UiFontSize = (typeof UI_FONT_SIZES)[number];
|
||||
export const DEFAULT_UI_FONT_SIZE: UiFontSize = 16;
|
||||
|
||||
export const TERMINAL_FONT_SIZES = [11, 12, 13, 14, 15, 16, 18, 20] as const;
|
||||
export const DEFAULT_TERMINAL_FONT_SIZE = 13;
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: number) {
|
||||
try {
|
||||
window.localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
/* storage blocked — this page still applies it */
|
||||
}
|
||||
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
||||
}
|
||||
|
||||
export function readUiFontSize(): UiFontSize {
|
||||
const n = Number(read(UI_FONT_SIZE_KEY));
|
||||
return (UI_FONT_SIZES as readonly number[]).includes(n)
|
||||
? (n as UiFontSize)
|
||||
: DEFAULT_UI_FONT_SIZE;
|
||||
}
|
||||
|
||||
/** Paint the stored interface size on `<html>` (the default leaves it unset). */
|
||||
export function applyUiFontSize(size: UiFontSize = readUiFontSize()) {
|
||||
const root = document.documentElement;
|
||||
if (size === DEFAULT_UI_FONT_SIZE) root.style.removeProperty("font-size");
|
||||
else root.style.fontSize = `${size}px`;
|
||||
}
|
||||
|
||||
export function writeUiFontSize(size: UiFontSize) {
|
||||
applyUiFontSize(size);
|
||||
write(UI_FONT_SIZE_KEY, size);
|
||||
}
|
||||
|
||||
export function readTerminalFontSize(): number {
|
||||
const n = Number(read(TERMINAL_FONT_SIZE_KEY));
|
||||
return (TERMINAL_FONT_SIZES as readonly number[]).includes(n) ? n : DEFAULT_TERMINAL_FONT_SIZE;
|
||||
}
|
||||
|
||||
export function writeTerminalFontSize(size: number) {
|
||||
write(TERMINAL_FONT_SIZE_KEY, size);
|
||||
}
|
||||
@@ -72,9 +72,26 @@ export const SETTINGS_INDEX: {
|
||||
section: "appearance",
|
||||
titleKey: "terminalFont.title",
|
||||
descKey: "terminalFont.description",
|
||||
keywords: ["font", "phông", "maple", "jetbrains", "monospace", "rounded", "bo tròn"],
|
||||
keywords: [
|
||||
"font",
|
||||
"phông",
|
||||
"maple",
|
||||
"jetbrains",
|
||||
"monospace",
|
||||
"rounded",
|
||||
"bo tròn",
|
||||
"size",
|
||||
"cỡ chữ",
|
||||
],
|
||||
to: "#terminal-font",
|
||||
},
|
||||
{
|
||||
section: "appearance",
|
||||
titleKey: "textSize.title",
|
||||
descKey: "textSize.description",
|
||||
keywords: ["font size", "text size", "zoom", "larger", "cỡ chữ", "chữ to", "phóng to"],
|
||||
to: "#text-size",
|
||||
},
|
||||
{
|
||||
section: "data",
|
||||
titleKey: "pushIngest.title",
|
||||
@@ -314,6 +331,12 @@ export const SETTINGS_INDEX: {
|
||||
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
|
||||
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
||||
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "gitIdentity.title",
|
||||
keywords: ["ssh", "key", "clone", "https"],
|
||||
to: "#git-identity",
|
||||
},
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "worktrees.showUntracked",
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
* terminal opens. `jetbrains` (the default) and the rounded `maple` are
|
||||
* bundled webfonts that cover every Vietnamese letter; `system` is the browser's own
|
||||
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
|
||||
* and those glyphs then fall back to a face of another width).
|
||||
* and those glyphs then fall back to a face of another width). A change fires
|
||||
* `FONT_SIZE_EVENT` (`lib/fontSize.ts`), so an open terminal takes it too.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { FONT_SIZE_EVENT } from "./fontSize";
|
||||
|
||||
export const TERMINAL_FONT_KEY = "agenthub.terminal.font";
|
||||
|
||||
export const TERMINAL_FONTS = {
|
||||
@@ -34,4 +37,5 @@ export function writeTerminalFont(font: TerminalFont) {
|
||||
} catch {
|
||||
/* storage blocked — terminals keep the default */
|
||||
}
|
||||
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
||||
}
|
||||
|
||||
@@ -741,6 +741,9 @@ export interface Session {
|
||||
/** ISO time the user archived the session out of its workspace's lists;
|
||||
* null when not archived. Hides only. Maps to `sessions.archived_at`. */
|
||||
archived_at?: string | null;
|
||||
/** ISO time the user pinned the session to the top of its workspace's
|
||||
* sidebar list; null when not pinned. Maps to `sessions.pinned_at`. */
|
||||
pinned_at?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useSyncExternalStore } from "react";
|
||||
import { api } from "./api";
|
||||
import i18n from "../i18n";
|
||||
import { applyStoredTheme } from "../hooks/useTheme";
|
||||
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
|
||||
|
||||
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
||||
export const PREF_KEYS = [
|
||||
@@ -27,6 +28,8 @@ export const PREF_KEYS = [
|
||||
"agenthub.theme.light",
|
||||
"i18nextLng",
|
||||
"agenthub.terminal.font",
|
||||
"agenthub.terminal.fontSize",
|
||||
"agenthub.ui.fontSize",
|
||||
"agenthub.pet",
|
||||
"agenthub.pet.size",
|
||||
"agenthub.pet.names",
|
||||
@@ -107,6 +110,8 @@ function reapply(prefs: Record<string, string>) {
|
||||
applyStoredTheme();
|
||||
window.dispatchEvent(new Event("agenthub-theme"));
|
||||
window.dispatchEvent(new Event("sagi:prefs"));
|
||||
applyUiFontSize();
|
||||
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
||||
const lng = prefs.i18nextLng;
|
||||
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user