Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,9 +376,12 @@ directly with no credentials or server proxy. Local files are uploaded only when
| | Without an account | Free account | Pay as you go |
| --- | --- | --- | --- |
| Largest file | 4 MB | 5 GB | 5 GB |
| Number of files | 1,000 (20 MB in all) | no limit | no limit |
| Number of files | 1,000 (20 MB in all) | 5,000 | 5,000 |
| Synced storage | none | 5 GB | $0.05 per GB a month over 5 GB |

The meter beside Sync in the editor's header shows storage and files against these
limits; click it for the plan, the price and the balance, and to add funds.

Signed in, a file over 4 MB is a *stored file*: it opens read-only (a PDF or image is
shown whole, text as its first megabyte) and Download gives it back exactly. On the
device it is kept in browser storage as it is; on the account it is end-to-end
Expand Down Expand Up @@ -735,7 +738,7 @@ explicitly overrides a conflict. Replaced local files receive numbered `.bak-NNN
backups. Browser replacements receive backups in IndexedDB, accessible through
**Sync → Local backups** for download or restoration. Clearing browser site data
also removes those local backups. The CLI syncs a readable workspace of up to 4 MB
per file and 20 MB in total; the browser and desktop app sync any number of files of
per file and 20 MB in total; the browser and desktop app sync up to 5,000 files of
up to 5 GB each (see File limits and storage).

The authenticated API exposes:
Expand Down
5 changes: 4 additions & 1 deletion desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const INLINE_BYTES = 4 * 1024 * 1024;
/** The largest file a signed-in account may keep (src/blob-store.ts); the page applies its own limits again. */
const MAX_FILE_BYTES = 5 * 1024 ** 3;
const MAX_RANGE = 16 * 1024 * 1024;
/** A signed-in workspace's file count (src/blob-store.ts); the walk stops there. */
const MAX_FILES = 5000;
const IGNORED = new Set(["node_modules", "dist", "build", "out", "target", "vendor", "coverage", "venv", "__pycache__", ".git"]);
const NEVER = /\.(?:zip|gz|tgz|bz2|xz|7z|rar|tar|exe|dll|so|dylib|o|a|class|jar|war|wasm|bin|dat|db|sqlite|sqlite3|mp3|mp4|m4a|mov|avi|mkv|webm|ogg|wav|flac|woff2?|ttf|otf|eot|psd|ai|doc|docx|xls|xlsx|ppt|pptx|iso|dmg|pkg|deb|rpm|apk|lock)$/i;
/** The bundled editor: inside the package, or the repo's site build when run with `electron .`. */
Expand All @@ -50,13 +52,14 @@ let window;
async function load(paths, cwd = process.cwd()) {
const files = [];
const add = async (path, info, relative) => {
if (info.size > MAX_FILE_BYTES) return;
if (info.size > MAX_FILE_BYTES || files.length >= MAX_FILES) return;
const data = info.size > INLINE_BYTES ? undefined : new Uint8Array(await readFile(path));
files.push({ path, name: basename(path), relative, size: info.size, data });
opened.add(path);
};
const walk = async (directory, prefix) => {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (files.length >= MAX_FILES) return;
if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue;
const path = join(directory, entry.name);
const relative = `${prefix}/${entry.name}`;
Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "readm3-desktop",
"productName": "readm3",
"version": "0.16.0",
"version": "0.17.0",
"private": true,
"description": "readm3 as a desktop app: a plain, fast text editor for Markdown and code.",
"author": "Profullstack, Inc. <hello@profullstack.com>",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/readm3",
"version": "0.16.0",
"version": "0.17.0",
"description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.",
"type": "module",
"license": "MIT",
Expand Down
9 changes: 9 additions & 0 deletions site/assets/viewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ html, body.reader-app { height: 100%; overflow: hidden; }
.media-view img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 6px; background: repeating-conic-gradient(color-mix(in srgb, var(--fg) 6%, transparent) 0 25%, transparent 0 50%) 0 0 / 20px 20px; }
.media-note { color: var(--muted); font: 11px var(--mono); }
.media-note a { color: var(--primary); }
.usage { display: inline-flex; align-items: center; gap: 8px; font: 11px var(--mono); color: var(--muted); white-space: nowrap; }
.usage-bar { width: 44px; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; flex: none; }
.usage-fill { display: block; height: 100%; width: 0; background: var(--success); }
.usage.near .usage-fill { background: var(--warning, #c98a00); }
.usage.over .usage-fill { background: var(--danger, #c0392b); }
.usage-table { display: grid; grid-template-columns: auto 1fr; gap: 6px 18px; margin: 14px 0 12px; }
.usage-table dt { color: var(--muted); }
.usage-table dd { margin: 0; font-family: var(--mono); }
@media (max-width: 1100px) { .usage-text { display: none; } }
.large-preview { margin: 12px 0 0; font: 13px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.reader-app.dragging::after { content: "Drop files here"; position: fixed; inset: 12px; display: grid; place-items: center; background: color-mix(in srgb, var(--bg) 95%, transparent); border: 2px dashed var(--primary); border-radius: 12px; color: var(--primary); font: 20px var(--mono); pointer-events: none; z-index: 10; }
[data-theme="light"] { color-scheme: light; }
Expand Down
1 change: 1 addition & 0 deletions site/assets/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<a class="brand" href="/">readm3<span class="app-label"> / editor</span></a>
<div class="header-actions">
<a href="/admin">Workspaces</a>
<button id="usage" class="usage" aria-haspopup="dialog" hidden><span class="usage-bar" aria-hidden="true"><span class="usage-fill"></span></span><span class="usage-text"></span></button>
<button id="sync-workspace">Sync</button>
<a class="account-link" href="/account">Account</a>
<span id="connection" class="connection">Browser workspace</span>
Expand Down
2 changes: 1 addition & 1 deletion site/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ function home(): string {
<div class="card"><h3>Keep the story of the document.</h3><p>Every online save creates a version. See who changed it, share a specific version, or restore an earlier draft without losing the history.</p></div>
<div class="card"><h3>A workspace for your team.</h3><p>Organize people into organizations and teams. Share with a whole group, a few collaborators, or anyone with your link.</p></div>
<div class="card"><h3>A plain text editor.</h3><p>Line numbers, multiple cursors, find and replace, folding, <code>Ctrl+P</code> to any file and <code>Ctrl+Shift+P</code> for every command. Open a folder from disk and <code>Ctrl+S</code> saves back to it. <a href="/viewer">Open the editor</a>.</p></div>
<div class="card"><h3>Files up to 5 GB, as many as you like.</h3><p>A free account lifts the limits: any number of files, each up to 5 GB, with 5 GB of end-to-end encrypted storage that syncs to every device. Need more? Pay as you go, $0.05 per GB a month, from a CoinPay balance.</p></div>
<div class="card"><h3>Files up to 5 GB, 5,000 of them.</h3><p>A free account lifts the limits: up to 5,000 files, each up to 5 GB, with 5 GB of end-to-end encrypted storage that syncs to every device. Need more? Pay as you go, $0.05 per GB a month, from a CoinPay balance.</p></div>
<div class="card"><h3>Take the editor with you.</h3><p>Install readm3 from your browser, or get the desktop app: Linux <a href="${REPO}/releases/latest/download/readm3-linux-x86_64.AppImage">AppImage</a> or <a href="${REPO}/releases/latest/download/readm3-linux-x86_64.flatpak">Flatpak</a>, macOS for <a href="${REPO}/releases/latest/download/readm3-mac-arm64.dmg">Apple silicon</a> or <a href="${REPO}/releases/latest/download/readm3-mac-x64.dmg">Intel</a>, <a href="${REPO}/releases/latest/download/readm3-windows-x64-setup.exe">Windows</a>. Then <code>readm3 edit .</code> opens a folder from the terminal, like <code>subl .</code>. Local files work offline; shared documents stay connected.</p></div>
<div class="card"><h3>Use the tools you already use.</h3><p>The web editor, CLI, API, and MCP work with the same documents and permissions. Your terminal and your agents can join the workflow.</p></div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions site/history-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,10 @@ export async function showTopUp(): Promise<void> {
catch { location.href = `/account?next=${encodeURIComponent(location.pathname + location.search)}`; return; }
const box = document.createElement("dialog");
box.className = "history-dialog topup-dialog";
box.setAttribute("aria-label", "Top up AI summaries");
box.setAttribute("aria-label", "Add funds");
const per = billing.multiplier === 2 ? "twice" : `${billing.multiplier} times`;
box.innerHTML = `<div class="dialog-header"><h2>Top up AI summaries</h2><button type="button" aria-label="Close">×</button></div>
<p>Pay as you go: each summary costs ${per} what the AI call costs us, usually a fraction of a cent. Your balance never expires.</p>
box.innerHTML = `<div class="dialog-header"><h2>Add funds</h2><button type="button" aria-label="Close">×</button></div>
<p>Pay as you go, from one balance that never expires: storage over the free 5 GB is $0.05 per GB a month, and each AI summary costs ${per} what the AI call costs us, usually a fraction of a cent.</p>
<p class="history-account">Balance ${dollars(billing.balanceMicros)}</p>
<form><div class="topup-packs"></div><label>Pay with <select name="chain">${Object.entries(CHAIN_LABELS).map(([value, label]) => `<option value="${value}">${label}</option>`).join("")}</select></label>
<p class="history-error" role="alert"></p><button class="primary-action" type="submit">Continue to CoinPay</button></form>`;
Expand Down
22 changes: 22 additions & 0 deletions site/test/sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,28 @@ test("a signed-in account opens a file over 4 MB read-only and syncs it as an en
expect((await stat((await (await downloading).path())!)).size).toBe(big.length);
});

test("the header shows usage against the plan's limits, signed out and signed in", async ({ page, context }) => {
await page.goto("/viewer");
const usage = page.locator("#usage");
await expect(usage).toBeVisible();
await expect(usage).toHaveAttribute("title", /\/ 20\.0 MB · \d+ \/ 1,000 files/);
await usage.click();
const dialog = page.getByRole("dialog", { name: "Usage and limits" });
await expect(dialog).toContainText("No account");
await expect(dialog.getByRole("link", { name: "Sign in (free)" })).toBeVisible();
await dialog.getByRole("button", { name: "Close dialog" }).click();

await register(context, username());
await page.reload();
await setPassphrase(page);
await expect(usage).toHaveAttribute("title", /\/ 5\.0 GB · \d+ \/ 5,000 files/);
await usage.click();
await expect(dialog).toContainText("Free account");
await expect(dialog).toContainText("$0.05 per GB a month");
await expect(dialog).toContainText("Balance");
await expect(dialog.getByRole("button", { name: "Add funds" })).toBeVisible();
});

test("signed out, a file over 4 MB asks for a free account", async ({ page }) => {
await page.goto("/viewer");
await page.locator("#files-input").setInputFiles({ name: "huge.md", mimeType: "text/markdown", buffer: Buffer.alloc(4 * 1024 * 1024 + 1, "a") });
Expand Down
108 changes: 108 additions & 0 deletions site/usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* The usage meter in the editor's header: files and storage against the plan's limits.
* Signed out it measures this device's workspace (1,000 files, 20 MB); signed in it
* measures the account (5,000 files, 5 GB stored free, then pay as you go). A click
* opens the limits, the price, the balance and a way to add funds or sign in.
*/
import { modal } from "./cloud.ts";
import * as stored from "./blobs.ts";
import { showTopUp } from "./history-panel.ts";
import { formatStorage, FREE_STORAGE_BYTES, MAX_ACCOUNT_FILES, MAX_BLOB_BYTES, STORAGE_MICROS_PER_GIB_MONTH } from "../src/blob-store.ts";

export const LOCAL_FILES = 1000;
export const LOCAL_BYTES = 20 * 1024 * 1024;
const LOCAL_FILE_BYTES = 4 * 1024 * 1024;

export interface LocalUsage { files: number; bytes: number; signedIn: boolean }

let account: stored.StorageAccount | null = null;
let fetchedAt = 0;
let last: LocalUsage = { files: 0, bytes: 0, signedIn: false };

const dollars = (micros: number) => `$${(micros / 1e6).toFixed(2)}`;
const count = (n: number) => n.toLocaleString();

/** Redraws the meter; the account's numbers are fetched at most every 30 s unless forced. */
export async function refreshUsage(local: LocalUsage, force = false) {
last = local;
if (!local.signedIn) account = null;
else if (force || !account || Date.now() - fetchedAt > 30_000) {
fetchedAt = Date.now();
try { account = await stored.account(); } catch { /* Offline: the last numbers stand. */ }
}
paint();
}

function numbers() {
if (last.signedIn) {
const used = account?.usedBytes ?? 0;
const free = account?.freeBytes ?? FREE_STORAGE_BYTES;
return { files: last.files, fileLimit: MAX_ACCOUNT_FILES, used, limit: free, over: used > free };
}
return { files: last.files, fileLimit: LOCAL_FILES, used: last.bytes, limit: LOCAL_BYTES, over: false };
}

function paint() {
const button = document.getElementById("usage");
if (!button) return;
const n = numbers();
const share = Math.min(1, Math.max(n.used / n.limit, n.files / n.fileLimit));
const text = `${formatStorage(n.used)} / ${formatStorage(n.limit)} · ${count(n.files)} / ${count(n.fileLimit)} files`;
button.hidden = false;
button.classList.toggle("near", share >= 0.8 && !n.over);
button.classList.toggle("over", n.over || share >= 1);
button.title = n.over ? `${text} · over the free storage, pay as you go` : text;
button.setAttribute("aria-label", `Usage and limits: ${button.title}`);
const bar = button.querySelector<HTMLElement>(".usage-fill");
// Anything in use shows at least a sliver, so an empty bar means an empty workspace.
if (bar) bar.style.width = `${share > 0 ? Math.max(4, Math.round(share * 100)) : 0}%`;
const label = button.querySelector<HTMLElement>(".usage-text");
if (label) label.textContent = n.over ? `${formatStorage(n.used)} · pay as you go` : text;
}

export function showUsage() {
const n = numbers();
const box = modal("Usage and limits");
const rows: [string, string][] = last.signedIn ? [
["Plan", n.over || (account?.balanceMicros ?? 0) > 0 ? "Free account, pay as you go" : "Free account"],
["Files", `${count(n.files)} of ${count(n.fileLimit)}`],
["Stored files", `${formatStorage(n.used)} of ${formatStorage(n.limit)} free`],
["Largest file", formatStorage(account?.maxFileBytes ?? MAX_BLOB_BYTES)],
["Over the free storage", `${dollars(account?.microsPerGibMonth ?? STORAGE_MICROS_PER_GIB_MONTH)} per GB a month`],
["Balance", account ? dollars(account.balanceMicros) : "unavailable offline"],
] : [
["Plan", "No account: this device only"],
["Files", `${count(n.files)} of ${count(n.fileLimit)}`],
["Workspace", `${formatStorage(n.used)} of ${formatStorage(n.limit)}`],
["Largest file", formatStorage(LOCAL_FILE_BYTES)],
];
const table = document.createElement("dl");
table.className = "usage-table";
for (const [term, value] of rows) {
const dt = document.createElement("dt");
dt.textContent = term;
const dd = document.createElement("dd");
dd.textContent = value;
table.append(dt, dd);
}
const note = document.createElement("p");
note.textContent = last.signedIn
? "Files over 4 MB are stored end-to-end encrypted on your account. Storage over the free allowance is charged hourly from your balance; with no balance, new uploads past it wait, and nothing stored is deleted."
: "A free account raises the limits to 5,000 files of up to 5 GB each, with 5 GB of encrypted storage that syncs to every device.";
const actions = document.createElement("p");
if (last.signedIn) {
const add = document.createElement("button");
add.type = "button";
add.className = "primary";
add.textContent = "Add funds";
add.onclick = () => { box.close(); void showTopUp(); };
actions.append(add);
} else {
const link = document.createElement("a");
link.className = "primary";
link.href = `/account?next=${encodeURIComponent("/viewer")}`;
link.textContent = "Sign in (free)";
actions.append(link);
}
box.body.append(table, note, actions);
}
Loading
Loading