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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.46",
3
+ "version": "0.11.47",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {