mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(webapp): copy a file's source to the clipboard (BEA-153) (#190)
You could read a doc in the hub and download it, but there was no way to get its text onto the clipboard — the workaround was downloading a file you didn't want, or select-all over rendered markdown, which silently mangles what you paste. Copy sits beside Download in the ⋯ menu and in the ⌘K palette, and puts the file's raw source there: the whole file, frontmatter included, so it round-trips with Download. With a version pinned (?v=) it copies THAT version's bytes, the same rule Download already follows. It fetches directly rather than reading the ["text", url] query cache: TextView stores a bare string under that key and SniffView stores a BlobText object, so a cache read would have refused .csv/.txt files while working fine on the markdown a reviewer would test with. copyText returns false instead of throwing, so the toast branches on it — otherwise every http:// self-host gets a success message over an empty clipboard. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
899a840439
commit
82e23f2382
@@ -1204,6 +1204,138 @@ test("read counts disclose that your own views count", async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
/* BEA-153: Copy. Download's counterpart — the same bytes, to the clipboard.
|
||||
The whole point is that it is SOURCE, so every assertion here reads the
|
||||
clipboard and looks for markdown syntax the rendered DOM does not contain.
|
||||
Chromium treats http://localhost as a secure context, so navigator.clipboard
|
||||
exists; the permission grant is the only thing standing between these specs
|
||||
and copyText() returning false by design. */
|
||||
|
||||
const COPY_MD = ["# Copy me", "", "| col | val |", "| --- | --- |", "| a | 1 |", "", "```sh", "echo hi", "```", ""].join(
|
||||
"\n",
|
||||
);
|
||||
|
||||
const clip = (page: import("@playwright/test").Page) =>
|
||||
page.evaluate(() => navigator.clipboard.readText());
|
||||
|
||||
test("Copy puts a markdown file's raw source on the clipboard", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.request.put(`/api/p/${pid}/upload/content?path=copy/doc.md`, { data: COPY_MD });
|
||||
|
||||
await page.goto(`/${pid}/copy/doc.md`);
|
||||
await expect(page.locator("#content h1")).toHaveText("Copy me");
|
||||
await page.click("#more-btn");
|
||||
await page.click("#more-menu .more-item:has-text('Copy')");
|
||||
await expectToast(page, "Copied copy/doc.md");
|
||||
// Source, not the rendered DOM: the heading marker, the table pipes and the
|
||||
// fence all survive. The page itself shows an <h1>, a <table> and a <pre> —
|
||||
// none of which contain these characters.
|
||||
expect(await clip(page)).toBe(COPY_MD);
|
||||
});
|
||||
|
||||
test("Copy on a .csv copies the delimited source, not the rendered table", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.request.put(`/api/p/${pid}/upload/content?path=copy/rows.csv`, { data: SALES_CSV });
|
||||
|
||||
await page.goto(`/${pid}/copy/rows.csv`);
|
||||
await expect(page.locator("#content table.csvview")).toBeVisible();
|
||||
await page.click("#more-btn");
|
||||
await page.click("#more-menu .more-item:has-text('Copy')");
|
||||
await expectToast(page, "Copied copy/rows.csv");
|
||||
expect(await clip(page)).toBe(SALES_CSV);
|
||||
});
|
||||
|
||||
test("Copy runs from the palette too", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/guide.md`);
|
||||
await expect(page.locator("#content")).toContainText("Second version");
|
||||
await page.keyboard.press("ControlOrMeta+k");
|
||||
await expect(page.locator("#palette")).toBeVisible();
|
||||
await page.fill("#palette input", "Copy: guide.md");
|
||||
await page.locator("#palette [cmdk-item]", { hasText: "Copy: guide.md" }).first().click();
|
||||
await expectToast(page, "Copied guide.md");
|
||||
expect(await clip(page)).toContain("# Guide");
|
||||
expect(await clip(page)).toContain("Second version");
|
||||
});
|
||||
|
||||
test("Copy on a pinned version yields THAT version's bytes", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/history/guide.md`);
|
||||
// The oldest row is the first version; the current file holds the second.
|
||||
await page.locator(".hentry.add").click();
|
||||
await page.waitForURL(new RegExp(`/${pid}/guide\\.md\\?v=[0-9a-f]{64}$`));
|
||||
await expect(page.locator("#content")).toContainText("First version");
|
||||
|
||||
await page.click("#more-btn");
|
||||
await page.click("#more-menu .more-item:has-text('Copy')");
|
||||
await expectToast(page, "Copied guide.md");
|
||||
const text = await clip(page);
|
||||
expect(text).toContain("First version");
|
||||
// The current file says "Second version" — a ⋯ menu that mixed the two is
|
||||
// exactly what BEA-7 fixed for Download.
|
||||
expect(text).not.toContain("Second version");
|
||||
});
|
||||
|
||||
test("Copy is absent for an image and for HTML", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.request.put(`/api/p/${pid}/upload/content?path=copy/page.html`, {
|
||||
data: "<h1>hello</h1>",
|
||||
});
|
||||
|
||||
for (const path of ["assets/logo.png", "copy/page.html"]) {
|
||||
await page.goto(`/${pid}/${path}`);
|
||||
await page.click("#more-btn");
|
||||
await expect(page.locator("#more-menu .more-item:has-text('Download')")).toBeVisible();
|
||||
await expect(page.locator("#more-menu .more-item:has-text('Copy')")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
// …and the palette does not offer it either, on the page that has no menu item.
|
||||
await page.keyboard.press("ControlOrMeta+k");
|
||||
await expect(page.locator("#palette")).toBeVisible();
|
||||
await page.fill("#palette input", "Copy: copy/page.html");
|
||||
await expect(page.locator("#palette [cmdk-item]", { hasText: "Copy: copy/page.html" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a clipboard that isn't there gets a failure toast, not a success one", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
// What an http:// self-host looks like from the page's side: no
|
||||
// navigator.clipboard, so copyText returns false by design. Without the
|
||||
// branch on that return value this is a "Copied" toast over an empty
|
||||
// clipboard — the failure mode the toast exists to prevent.
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "clipboard", { get: () => undefined });
|
||||
});
|
||||
await page.goto(`/${pid}/index.md`);
|
||||
await page.click("#more-btn");
|
||||
await page.click("#more-menu .more-item:has-text('Copy')");
|
||||
await expectToast(page, /Copy failed .* secure \(https\) origin/);
|
||||
await expect(page.locator("#toast.show, [data-sonner-toast]").filter({ hasText: "Copied" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a read-only member can copy — it is a read, like Download", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await login(page, READER);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/index.md`);
|
||||
await page.click("#more-btn");
|
||||
await page.click("#more-menu .more-item:has-text('Copy')");
|
||||
await expectToast(page, "Copied index.md");
|
||||
expect(await clip(page)).toContain("# Wiki");
|
||||
});
|
||||
|
||||
/* BEA-155: the scroll restorer. Reading a file is never interrupted by a
|
||||
background refresh — the read-count poll used to call onRendered through
|
||||
MarkdownView's meta effect, and the restorer read that as "content landed"
|
||||
|
||||
@@ -11,10 +11,11 @@ import { atLeast } from "../api/types";
|
||||
import { getJSON, postJSON } from "../api/http";
|
||||
import type { Project, ServerConfig, UndoPlan } from "../api/types";
|
||||
import { useHeat, useTree } from "../hooks/useBrowse";
|
||||
import { fetchBlobText, fileURLFor } from "../hooks/useBlob";
|
||||
import { useShares } from "../hooks/useHub";
|
||||
import { urlForPath, urlForView, type Route } from "../router";
|
||||
import { currentNavType, navigate, useLocationPath } from "../nav";
|
||||
import { HTML_EXT, PDF_EXT, copyText } from "../util";
|
||||
import { HTML_EXT, IMG_EXT, PDF_EXT, copyText } from "../util";
|
||||
import { toast } from "../toast";
|
||||
import { modalConfirm } from "../modal";
|
||||
import { onSearchRequest } from "../search";
|
||||
@@ -217,6 +218,11 @@ export default function Browser(props: {
|
||||
// Browser upload is deliberately absent (for now): content enters through
|
||||
// local sync only; the web app is a read/share/history surface.
|
||||
const canDownload = !panel && isFile;
|
||||
// Stated as exclusions rather than an allowlist, so a file with no
|
||||
// extension or an unknown one — the sniffed-as-text case the viewer
|
||||
// already renders as text — still offers Copy. Images, PDFs and the HTML
|
||||
// iframe are the three things a clipboard cannot usefully hold.
|
||||
const canCopy = canDownload && !IMG_EXT.test(path) && !PDF_EXT.test(path) && !HTML_EXT.test(path);
|
||||
const canMore = !panel && (isFile || (hub && !!project && isDir));
|
||||
// Downloading while a version is open gives you THAT version — the ⋯ menu
|
||||
// offering the current bytes under a page framed as historical was half of
|
||||
@@ -225,6 +231,30 @@ export default function Browser(props: {
|
||||
? apiBase + "blob?sha=" + version + "&name=" + encodeURIComponent(path) + "&download=1"
|
||||
: apiBase + "download?path=" + encodeURIComponent(path);
|
||||
|
||||
// Copy is Download's counterpart: the same bytes, to the clipboard instead
|
||||
// of to disk — whole file, frontmatter included, so what you paste back is
|
||||
// the file. It re-fetches rather than reading the ["text", url] query cache
|
||||
// on purpose: TextView stores a bare string under that key and SniffView
|
||||
// stores a BlobText object, so a cache read would refuse .csv/.txt files
|
||||
// while working fine on the markdown a reviewer would test with.
|
||||
const copyNow = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchBlobText(fileURLFor(apiBase, path, version));
|
||||
if (data.kind !== "text")
|
||||
return toast(
|
||||
data.kind === "too-large" ? "Too large to copy — use Download." : "That file isn't text — use Download.",
|
||||
true,
|
||||
);
|
||||
// copyText returns false instead of throwing (util.ts), so branching on
|
||||
// it is the whole difference between a failure toast and a success
|
||||
// message over an empty clipboard on every http:// self-host.
|
||||
const ok = await copyText(data.text);
|
||||
toast(ok ? "Copied " + path : "Copy failed — the clipboard needs a secure (https) origin.", !ok);
|
||||
} catch (err) {
|
||||
toast("Copy failed: " + (err as Error).message, true);
|
||||
}
|
||||
}, [apiBase, path, version]);
|
||||
|
||||
const shareNow = useCallback(async () => {
|
||||
// Shares are per-file; a selected folder has nothing to mint.
|
||||
const post = (confirm: boolean) =>
|
||||
@@ -472,6 +502,7 @@ export default function Browser(props: {
|
||||
if (isFile) add("share", "Share: " + path, "action", shareNow);
|
||||
add("hist", "History: " + path, "action", historyNow);
|
||||
if (isFile) add("download", "Download: " + path, "action", () => downloadRef.current?.click());
|
||||
if (canCopy) add("copy", "Copy: " + path, "action", copyNow);
|
||||
}
|
||||
if (hub && project) add("hist", "History: whole project", "action", () => openHistory(""));
|
||||
if (hub) {
|
||||
@@ -487,7 +518,7 @@ export default function Browser(props: {
|
||||
for (const d of dirIndex.keys()) add("folder", d, "folder", () => openPath(d));
|
||||
for (const f of flatFiles) add("doc", f.path, "file", () => openPath(f.path));
|
||||
return items;
|
||||
}, [hub, project, path, isFile, config.auth?.enabled, dirIndex, flatFiles, props.projects, props.onClosePanel, shareNow, historyNow, openHistory, openPath]);
|
||||
}, [hub, project, path, isFile, canCopy, config.auth?.enabled, dirIndex, flatFiles, props.projects, props.onClosePanel, shareNow, copyNow, historyNow, openHistory, openPath]);
|
||||
|
||||
/* ---- "⋯ More" menu (secondary actions on narrow screens) ---- */
|
||||
useEffect(() => {
|
||||
@@ -725,6 +756,11 @@ export default function Browser(props: {
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
{canCopy && (
|
||||
<button className="more-item" onClick={copyNow}>
|
||||
Copy
|
||||
</button>
|
||||
)}
|
||||
{hub && !!project && (
|
||||
<button
|
||||
className="more-item"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getJSON } from "../api/http";
|
||||
import type { FrontmatterPair, HeatMap, Node, RenderDoc } from "../api/types";
|
||||
import { heatTotal, heatText } from "../hooks/useBrowse";
|
||||
import { HEAT_DISCLOSURE, staleNote } from "../lib/heat";
|
||||
import { useTextAt } from "../hooks/useBlob";
|
||||
import { fileURLFor, useTextAt } from "../hooks/useBlob";
|
||||
import {
|
||||
CSV_EXT,
|
||||
HTML_EXT,
|
||||
@@ -39,12 +39,7 @@ export function FileView(props: {
|
||||
onRendered?: () => void;
|
||||
}) {
|
||||
const { apiBase, path, version, onMeta } = props;
|
||||
// A version is served by content hash; ?name= is what makes the server
|
||||
// set a real Content-Type, so images and text render instead of
|
||||
// downloading as octet-stream.
|
||||
const fileURL = version
|
||||
? apiBase + "blob?sha=" + version + "&name=" + encodeURIComponent(path)
|
||||
: apiBase + "file?path=" + encodeURIComponent(path);
|
||||
const fileURL = fileURLFor(apiBase, path, version);
|
||||
|
||||
useEffect(() => () => onMeta(""), [path, onMeta]); // leaving a file clears its meta line
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export function blobURL(apiBase: string, sha: string, name?: string, download?:
|
||||
return u;
|
||||
}
|
||||
|
||||
async function fetchBlobText(url: string): Promise<BlobText> {
|
||||
export async function fetchBlobText(url: string): Promise<BlobText> {
|
||||
const r = await getResponse(url);
|
||||
// Cheap out before reading the body when the server tells us the size.
|
||||
// Content-Length is a hint, not a guarantee (a chunked or proxied
|
||||
@@ -26,6 +26,17 @@ async function fetchBlobText(url: string): Promise<BlobText> {
|
||||
return sniffBytes(new Uint8Array(await r.arrayBuffer()));
|
||||
}
|
||||
|
||||
// The URL a file page reads its bytes from: content-addressed when a version
|
||||
// is pinned, the live path otherwise. Exported so Copy builds the same URL
|
||||
// the view does instead of a second copy of the expression that can drift.
|
||||
// A version is served by content hash; ?name= is what makes the server set a
|
||||
// real Content-Type, so images and text render instead of downloading as
|
||||
// octet-stream. Note `file?path=`, not the `download?path=` an <a download>
|
||||
// points at — same bytes, but Content-Disposition is meaningless to a fetch.
|
||||
export function fileURLFor(apiBase: string, path: string, version?: string): string {
|
||||
return version ? blobURL(apiBase, version, path) : apiBase + "file?path=" + encodeURIComponent(path);
|
||||
}
|
||||
|
||||
// `immutable` is for content-addressed URLs: a sha's bytes never change, so
|
||||
// staleness cannot apply and re-expanding a history row costs no request. A
|
||||
// live path must never be pinned that way — a teammate's edit would keep
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-DB7i1Dhi.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D4PhKgpJ.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-L-I4D1mx.css">
|
||||
|
||||
Reference in New Issue
Block a user