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
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.30.0",
"version": "0.31.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.30.0",
"version": "0.31.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
1 change: 1 addition & 0 deletions site/assets/viewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ html, body.reader-app { height: 100%; overflow: hidden; }
.reader-selects select { padding: 3px 5px; font-size: 11px; max-width: 132px; }
.reader-app #spoilers { padding: 3px 7px; font-size: 11px; color: var(--muted); }
.reader-app #spoilers[aria-pressed="true"] { color: var(--primary); border-color: var(--primary); }
.reader-app #disk-direct[aria-pressed="true"] { color: var(--primary); border-color: var(--primary); background: color-mix(in srgb, var(--primary) 14%, transparent); }
#reader { flex: 1; min-height: 0; overflow: auto; padding: 27px clamp(20px, 4vw, 64px) 50px; outline-offset: -3px; }
#document-content { width: fit-content; max-width: none; margin: 0; padding: 0; font: 14px/1.85 var(--mono); color: var(--fg); white-space: pre; tab-size: 2; }
#editor { flex: 1; min-height: 0; display: flex; background: var(--bg); }
Expand Down
19 changes: 18 additions & 1 deletion site/test/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,24 @@ test("in the desktop app, edits go straight to disk by default, and a single tab
const close = page.locator("#tabs .tab-close");
await expect(close).toBeVisible();
await close.click();
await expect(page.locator("#document-name")).not.toHaveText("list.txt");
// Nothing open: an empty Untitled document, kept nowhere until something is typed.
await expect(page.locator("#document-name")).toHaveText("Untitled.md");
await expect.poll(() => editorText(page)).toBe("");
await page.waitForTimeout(500);
await page.reload();
await expect(page.locator('#file-list button[title="Untitled.md"]')).toHaveCount(0);
await page.locator("#tabs .tab-close").first().click();
await expect(page.locator("#document-name")).toHaveText("Untitled.md");
await editorInput(page).fill("# Shopping\n");
await page.waitForTimeout(500);
await page.reload();
await expect(page.locator('#file-list button[title="Untitled.md"]')).toHaveCount(1);
// The toggle shows when it is on.
const toggle = page.locator("#disk-direct");
const on = await toggle.evaluate((button) => getComputedStyle(button).borderColor);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "false");
expect(await toggle.evaluate((button) => getComputedStyle(button).borderColor)).not.toBe(on);
});

test("in the desktop app, a PDF read from disk still opens after a restart, without opening its folder again", async ({ page }) => {
Expand Down
60 changes: 45 additions & 15 deletions site/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,14 +276,48 @@ function notice(message: string) {

function current(): Document { return workspace.documents.find((doc) => doc.path === workspace.active)!; }

/**
* With nothing else open, an empty Untitled document is on screen, as in any editor. It is
* kept nowhere (not this device's storage, not sync, not disk) until something is typed in
* it; then it is a note like any other, and Ctrl+S picks where it goes on disk.
*/
let blankPath = "";
const isBlank = (doc: Document) => !!blankPath && doc.path === blankPath && doc.source === "";
/** The workspace as stored and synced: the untouched blank document left out. */
function keptWorkspace(): Workspace {
if (!blankPath) return workspace;
const documents = workspace.documents.filter((doc) => !isBlank(doc));
if (!documents.length || documents.length === workspace.documents.length) return workspace;
const active = documents.some((doc) => doc.path === workspace.active) ? workspace.active : documents[0]!.path;
return { ...workspace, active, documents };
}
function openBlank() {
if (!(blankPath && workspace.documents.some(isBlank))) {
const taken = new Set(workspace.documents.map((doc) => doc.path));
let path = "Untitled.md";
for (let n = 2; taken.has(path); n++) path = `Untitled ${n}.md`;
workspace.documents = [...workspace.documents, { path, source: "" }].sort((a, b) => comparePaths(a.path, b.path));
blankPath = path;
}
tabs = [blankPath];
openDocument(blankPath);
setMode(true, true);
}
/** Leaving the blank document untouched drops it. */
function dropBlank(next: string) {
if (!blankPath || next === blankPath) return;
if (workspace.documents.some(isBlank)) workspace.documents = workspace.documents.filter((doc) => !isBlank(doc));
blankPath = "";
}

async function save() {
if (pasteDocument) return;
if (cloudDocument) { await saveCloud(); return; }
clearTimeout(saveTimer);
const savingRevision = revision;
pendingSave = true;
try {
await persist(workspace);
await persist(keptWorkspace());
if (revision === savingRevision) {
// Every saved edit reaches the account a few seconds later, unless it came from sync.
if (!applyingSync) scheduleSync();
Expand Down Expand Up @@ -373,7 +407,7 @@ function renderTabs() {
close.className = "tab-close";
close.textContent = "×";
close.setAttribute("aria-label", `Close ${path}`);
close.disabled = workspace.documents.length < 2;
close.disabled = path === blankPath && tabs.length === 1 && workspace.documents.some(isBlank);
close.onclick = () => closeTab(path);
tab.append(open, close);
return tab;
Expand All @@ -386,11 +420,10 @@ function renderTabs() {

function closeTab(path: string) {
// The last tab gives way to another file: one file is always on screen.
// The last tab closes to an empty Untitled document, unless that is what is closing.
if (tabs.length < 2) {
const next = workspace.documents.find((doc) => doc.path !== path)?.path;
if (!next) return;
tabs = [next];
openDocument(next);
if (path === blankPath && workspace.documents.some(isBlank)) return;
openBlank();
return;
}
const index = tabs.indexOf(path);
Expand All @@ -404,12 +437,8 @@ function closeOtherTabs(path: string) {
if (path === workspace.active) renderTabs(); else openDocument(path);
}

/** Every tab closes; one file is always on screen, so the first in the file tree takes its place. */
function closeAllTabs() {
const first = workspace.documents[0]!.path;
tabs = [first];
if (first === workspace.active) renderTabs(); else openDocument(first);
}
/** Every tab closes, and an empty Untitled document takes their place. */
function closeAllTabs() { openBlank(); }

/** Right-click on a tab: reload it from disk, or close it, the others, or all of them. */
const tabMenu = element("tab-menu");
Expand All @@ -426,9 +455,9 @@ function showTabMenu(path: string, x: number, y: number) {
tabMenu.replaceChildren(
item("Reload from disk", () => void reloadFromDisk(path)),
document.createElement("hr"),
item("Close tab", () => closeTab(path), workspace.documents.length < 2),
item("Close tab", () => closeTab(path), path === blankPath && tabs.length === 1),
item("Close all but this tab", () => closeOtherTabs(path), tabs.length < 2),
item("Close all tabs", closeAllTabs, tabs.length < 2 && tabs[0] === workspace.documents[0]?.path),
item("Close all tabs", closeAllTabs, path === blankPath && tabs.length === 1),
);
tabMenu.hidden = false;
const { width, height } = tabMenu.getBoundingClientRect();
Expand Down Expand Up @@ -791,6 +820,7 @@ function documentInfo() {
}

function openDocument(path: string, focus = true) {
dropBlank(path);
workspace.active = path;
element("document-name").textContent = path;
document.title = `${path.split("/").at(-1)} — readm3`;
Expand Down Expand Up @@ -1643,7 +1673,7 @@ async function start() {
void setupPwa();
setupOpenInDesktop();
startAutoSync({
read: () => ({ name: workspace.name, active: workspace.active, documents: workspace.documents }),
read: () => { const kept = keptWorkspace(); return { name: kept.name, active: kept.active, documents: kept.documents }; },
settings: () => ({ theme: theme.value, flavor: flavor.value as Flavor }),
apply: (incoming, settings) => applySynced(incoming as Workspace, settings),
status: (text) => { element("sync-note").textContent = text; if (text.startsWith("Synced")) updateUsage(true); },
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { run, type ViewerOptions } from "./viewer.ts";
import { isCloudCommand, cloudMain, CLOUD_USAGE } from "./cloud-cli.ts";
import { isDesktopCommand, desktopMain, installedDesktop, prefersDesktop, DESKTOP_USAGE } from "./desktop-cli.ts";

export const VERSION = "0.30.0";
export const VERSION = "0.31.0";

/** The help text, exported so a test can hold it to the flags it documents. */
export const USAGE = `readm3 — a plain text editor for Markdown and code
Expand Down
Loading