hop2 0.11.46 → 0.11.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -123,6 +123,27 @@ const formatBytes = (n?: number) => {
|
|
|
123
123
|
const hrefFor = (item: ViewItem) =>
|
|
124
124
|
item.kind === "server" && item.target ? item.target : item.path;
|
|
125
125
|
|
|
126
|
+
// The stored file itself, as an attachment: the daemon's /download mode
|
|
127
|
+
// serves the markdown an agent wrote rather than the page it renders into.
|
|
128
|
+
// Only files — a server is live, there is nothing to save.
|
|
129
|
+
const downloadHrefFor = (item: ViewItem) =>
|
|
130
|
+
item.kind === "server" ? null : item.path.replace(/\/inline$/, "") + "/download";
|
|
131
|
+
|
|
132
|
+
// A hidden same-origin anchor with `download`: the row is already an <a>
|
|
133
|
+
// (anchors cannot nest), and window.open would show the file, not save it.
|
|
134
|
+
const triggerDownload = (item: ViewItem) => {
|
|
135
|
+
const href = downloadHrefFor(item);
|
|
136
|
+
if (!href) return;
|
|
137
|
+
const a = document.createElement("a");
|
|
138
|
+
a.href = href;
|
|
139
|
+
a.download = item.name;
|
|
140
|
+
a.rel = "noopener";
|
|
141
|
+
a.style.display = "none";
|
|
142
|
+
document.body.appendChild(a);
|
|
143
|
+
a.click();
|
|
144
|
+
a.remove();
|
|
145
|
+
};
|
|
146
|
+
|
|
126
147
|
const extOf = (name: string) => (name.split(".").pop() || "").toLowerCase();
|
|
127
148
|
const isVideo = (name: string) => ["mp4", "mov", "webm", "m4v", "ogv"].includes(extOf(name));
|
|
128
149
|
const isAudio = (name: string) => ["mp3", "m4a", "wav", "flac", "aac", "ogg", "oga", "opus"].includes(extOf(name));
|
|
@@ -427,10 +448,25 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
427
448
|
<button type="button" className="views-act"
|
|
428
449
|
title="Open in a new tab" aria-label="Open in a new tab"
|
|
429
450
|
onClick={() => window.open(hrefFor(item), "_blank", "noopener")}>↗</button>
|
|
451
|
+
{!isServer && (
|
|
452
|
+
<button type="button" className="views-act"
|
|
453
|
+
title="Download" aria-label={`Download ${item.name}`}
|
|
454
|
+
onClick={() => triggerDownload(item)}>⤓</button>
|
|
455
|
+
)}
|
|
430
456
|
<button type="button" className="views-act"
|
|
431
457
|
title={copied === item.path ? "Copied" : "Copy link"}
|
|
432
458
|
aria-label="Copy link"
|
|
433
459
|
onClick={() => copyLink(item)}>{copied === item.path ? "✓" : "⧉"}</button>
|
|
460
|
+
{!isServer && (
|
|
461
|
+
/* The FILE, not the page: ?download=1 serves the published bytes as
|
|
462
|
+
an attachment under their real name (md/csv/json raw, not the
|
|
463
|
+
rendered view). A window.open, not an anchor: anchors can't nest
|
|
464
|
+
in the row's own link, and the attachment header keeps the tab
|
|
465
|
+
from navigating. */
|
|
466
|
+
<button type="button" className="views-act"
|
|
467
|
+
title="Download" aria-label="Download"
|
|
468
|
+
onClick={() => window.open(item.path + "?download=1", "_blank", "noopener")}>⤓</button>
|
|
469
|
+
)}
|
|
434
470
|
{!isServer && (
|
|
435
471
|
<button type="button"
|
|
436
472
|
className={"views-act danger" + (armedDelete === item.path ? " armed" : "")}
|
|
@@ -484,6 +520,11 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
484
520
|
{preview.title || preview.name}
|
|
485
521
|
</span>
|
|
486
522
|
<span className="views-head-spacer" />
|
|
523
|
+
{preview.kind !== "server" && (
|
|
524
|
+
<button type="button" className="views-act"
|
|
525
|
+
title="Download" aria-label={`Download ${preview.name}`}
|
|
526
|
+
onClick={() => triggerDownload(preview)}>⤓</button>
|
|
527
|
+
)}
|
|
487
528
|
<button type="button" className="views-act"
|
|
488
529
|
title="Open in a new tab" aria-label="Open preview in a new tab"
|
|
489
530
|
onClick={() => window.open(hrefFor(preview), "_blank", "noopener")}>↗</button>
|
|
@@ -28,6 +28,31 @@ beforeEach(() => {
|
|
|
28
28
|
afterEach(() => { cleanup(); vi.clearAllMocks(); vi.unstubAllGlobals(); });
|
|
29
29
|
|
|
30
30
|
describe("ViewsPanel", () => {
|
|
31
|
+
it("Download saves the stored file itself through a same-origin anchor, in the list and in the preview", async () => {
|
|
32
|
+
// The row is an <a>, so the button clicks a hidden anchor of its own;
|
|
33
|
+
// capture what that anchor asked the browser to do.
|
|
34
|
+
const clicked: Array<{ href: string; download: string }> = [];
|
|
35
|
+
const origClick = HTMLAnchorElement.prototype.click;
|
|
36
|
+
HTMLAnchorElement.prototype.click = function (this: HTMLAnchorElement) {
|
|
37
|
+
clicked.push({ href: this.getAttribute("href") || "", download: this.download });
|
|
38
|
+
};
|
|
39
|
+
try {
|
|
40
|
+
render(<ViewsPanel onClose={() => {}} />);
|
|
41
|
+
await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
|
|
42
|
+
fireEvent.click(screen.getByRole("button", { name: "Download agent-result.md" }));
|
|
43
|
+
expect(clicked).toEqual([{ href: "/view/Orion/agent-result.md/download", download: "agent-result.md" }]);
|
|
44
|
+
// Open the preview: its header offers the same download.
|
|
45
|
+
fireEvent.click(screen.getByText("Views end-to-end").closest("a") as HTMLAnchorElement);
|
|
46
|
+
const inPreview = screen.getAllByRole("button", { name: "Download agent-result.md" });
|
|
47
|
+
expect(inPreview.length).toBeGreaterThanOrEqual(2);
|
|
48
|
+
fireEvent.click(inPreview[inPreview.length - 1]);
|
|
49
|
+
expect(clicked.length).toBe(2);
|
|
50
|
+
expect(clicked[1].href).toBe("/view/Orion/agent-result.md/download");
|
|
51
|
+
} finally {
|
|
52
|
+
HTMLAnchorElement.prototype.click = origClick;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
31
56
|
it("a plain click previews IN PLACE; the row stays a real link for modified clicks", async () => {
|
|
32
57
|
render(<ViewsPanel onClose={() => {}} />);
|
|
33
58
|
await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
|
package/hop
CHANGED
|
@@ -10793,8 +10793,12 @@ happens until someone clicks it.
|
|
|
10793
10793
|
// file name. The legacy /assets/view/<s>/<f> form redirects here, so
|
|
10794
10794
|
// an edge-cached response is at worst a redirect, never the bytes.
|
|
10795
10795
|
const viewMatch = (req.method === 'GET' || req.method === 'HEAD')
|
|
10796
|
-
&& parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline)?$/);
|
|
10796
|
+
&& parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline|\/download)?$/);
|
|
10797
10797
|
if (viewMatch) {
|
|
10798
|
+
// /download: the stored file itself, as an attachment — the
|
|
10799
|
+
// markdown an agent wrote rather than the page it renders into,
|
|
10800
|
+
// the PDF rather than the viewer. Same bytes `hop view` copied in.
|
|
10801
|
+
const wantDownload = viewMatch[3] === '/download';
|
|
10798
10802
|
const viewRoot = path.resolve(VIEWS_DIR);
|
|
10799
10803
|
const target = path.resolve(path.join(
|
|
10800
10804
|
viewRoot, decodeURIComponent(viewMatch[1]), decodeURIComponent(viewMatch[2])));
|
|
@@ -10805,11 +10809,31 @@ happens until someone clicks it.
|
|
|
10805
10809
|
return;
|
|
10806
10810
|
}
|
|
10807
10811
|
const viewExt = path.extname(target).toLowerCase();
|
|
10812
|
+
// ?download=1 — the FILE, not the page: the bytes the agent
|
|
10813
|
+
// published, under their real name, as an attachment. Rendered
|
|
10814
|
+
// kinds (md/csv/json) come back raw here, not as the HTML the
|
|
10815
|
+
// viewer shows (Jian: "I should be able to download a hop view
|
|
10816
|
+
// item from the UI, right?").
|
|
10817
|
+
if (parsedUrl.query.download === '1') {
|
|
10818
|
+
const rawTypes = { '.md': 'text/markdown; charset=utf-8', '.markdown': 'text/markdown; charset=utf-8',
|
|
10819
|
+
'.csv': 'text/csv; charset=utf-8', '.tsv': 'text/tab-separated-values; charset=utf-8',
|
|
10820
|
+
'.json': 'application/json; charset=utf-8', '.txt': 'text/plain; charset=utf-8' };
|
|
10821
|
+
const dlName = path.basename(target).replace(/["\r\n]/g, '_');
|
|
10822
|
+
res.writeHead(200, {
|
|
10823
|
+
'Content-Type': rawTypes[viewExt] || STATIC_CONTENT_TYPES[viewExt] || 'application/octet-stream',
|
|
10824
|
+
'Content-Length': fs.statSync(target).size,
|
|
10825
|
+
'Content-Disposition': `attachment; filename="${dlName}"; filename*=UTF-8''${encodeURIComponent(dlName)}`,
|
|
10826
|
+
'Cache-Control': 'no-store'
|
|
10827
|
+
});
|
|
10828
|
+
if (req.method === 'HEAD') { res.end(); return; }
|
|
10829
|
+
fs.createReadStream(target).pipe(res);
|
|
10830
|
+
return;
|
|
10831
|
+
}
|
|
10808
10832
|
// Markdown is rendered, not dumped: an agent's write-up is a
|
|
10809
10833
|
// RESULT, and handing over a wall of raw asterisks is the "just
|
|
10810
10834
|
// text" outcome this feature exists to avoid. The stored file
|
|
10811
10835
|
// stays the markdown the agent wrote.
|
|
10812
|
-
if (VIEW_RENDERED.has(viewExt)) {
|
|
10836
|
+
if (VIEW_RENDERED.has(viewExt) && !wantDownload) {
|
|
10813
10837
|
const sess = decodeURIComponent(viewMatch[1]);
|
|
10814
10838
|
const file = decodeURIComponent(viewMatch[2]);
|
|
10815
10839
|
const caption = readViewMeta(sess)[file]?.title || file;
|
|
@@ -10867,7 +10891,15 @@ happens until someone clicks it.
|
|
|
10867
10891
|
fs.createReadStream(target, { start, end }).pipe(res);
|
|
10868
10892
|
return;
|
|
10869
10893
|
}
|
|
10894
|
+
// RFC 6266: an ASCII fallback plus the UTF-8 form for names with
|
|
10895
|
+
// anything else in them.
|
|
10896
|
+
const downloadName = path.basename(target);
|
|
10897
|
+
const asciiName = downloadName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
10898
|
+
const disposition = wantDownload
|
|
10899
|
+
? { 'Content-Disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(downloadName)}` }
|
|
10900
|
+
: {};
|
|
10870
10901
|
res.writeHead(200, {
|
|
10902
|
+
...disposition,
|
|
10871
10903
|
'Content-Type': viewType,
|
|
10872
10904
|
'Content-Length': viewSize,
|
|
10873
10905
|
'Accept-Ranges': 'bytes'
|
package/package.json
CHANGED