hop2 0.11.13 → 0.11.15
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.
- package/README.md +31 -0
- package/hay/apps/web/src/App.tsx +96 -11
- package/hay/apps/web/src/components/SessionSwitcher.tsx +47 -2
- package/hay/apps/web/src/components/ViewsPanel.tsx +243 -0
- package/hay/apps/web/src/styles.css +109 -0
- package/hay/apps/web/src/utils/fileDrop.ts +19 -3
- package/hay/apps/web/src/utils/switcherModel.ts +12 -0
- package/hay/apps/web/test/SessionSwitcher.test.tsx +19 -0
- package/hay/apps/web/test/ViewsPanel.test.tsx +118 -0
- package/hay/apps/web/test/fileDrop.test.ts +25 -1
- package/hay-web/assets/digest-archive.json +1024 -292
- package/hay-web/assets/index-Bcl-2CbE.js +133 -0
- package/hay-web/assets/index-Bcl-2CbE.js.map +1 -0
- package/hay-web/assets/{index-DHotLq-D.css → index-DKWcSY-E.css} +1 -1
- package/hay-web/index.html +2 -2
- package/hop +629 -63
- package/package.json +1 -1
- package/skills/hop-session/SKILL.md +32 -11
- package/tests/terminal-api.integration.test.cjs +84 -0
- package/hay-web/assets/index--8sYRyoP.js +0 -133
- package/hay-web/assets/index--8sYRyoP.js.map +0 -1
- package/hay-web/assets/index-D4EXRUpb.css +0 -1
- package/hay-web/assets/index-DnSA-VR6.js +0 -133
- package/hay-web/assets/index-DnSA-VR6.js.map +0 -1
- package/hay-web/assets/index-DzlPn2In.js +0 -133
- package/hay-web/assets/index-DzlPn2In.js.map +0 -1
|
@@ -4019,3 +4019,112 @@ button.primary {
|
|
|
4019
4019
|
.digest-headline { font-size: 12.5px; font-weight: 600; color: var(--ink); }
|
|
4020
4020
|
.digest-item.read .digest-headline { color: var(--ink-muted); font-weight: 500; }
|
|
4021
4021
|
.digest-why { font-size: 11.5px; color: var(--ink-muted); }
|
|
4022
|
+
|
|
4023
|
+
/* ── Views: the index of what agents published with `hop view`. Layered above
|
|
4024
|
+
the switcher (600) and its sheets (620) because it opens FROM them. ── */
|
|
4025
|
+
.views-backdrop {
|
|
4026
|
+
position: fixed;
|
|
4027
|
+
inset: 0;
|
|
4028
|
+
z-index: 720;
|
|
4029
|
+
background: rgba(0, 0, 0, 0.45);
|
|
4030
|
+
backdrop-filter: blur(2px);
|
|
4031
|
+
-webkit-backdrop-filter: blur(2px);
|
|
4032
|
+
}
|
|
4033
|
+
.views-panel {
|
|
4034
|
+
position: fixed;
|
|
4035
|
+
top: 50%;
|
|
4036
|
+
left: 50%;
|
|
4037
|
+
transform: translate(-50%, -50%);
|
|
4038
|
+
z-index: 730;
|
|
4039
|
+
width: min(460px, 92vw);
|
|
4040
|
+
/* Bounded, not sized to content: a fleet that has been publishing for a week
|
|
4041
|
+
otherwise grows a dialog taller than the screen with no way to scroll it. */
|
|
4042
|
+
max-height: min(74vh, 620px);
|
|
4043
|
+
display: flex;
|
|
4044
|
+
flex-direction: column;
|
|
4045
|
+
background: var(--panel);
|
|
4046
|
+
border: 1px solid var(--line);
|
|
4047
|
+
border-radius: 12px;
|
|
4048
|
+
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
|
4049
|
+
padding: 14px 16px;
|
|
4050
|
+
}
|
|
4051
|
+
.views-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
4052
|
+
.views-head-spacer { flex: 1; }
|
|
4053
|
+
.views-badge {
|
|
4054
|
+
font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
|
|
4055
|
+
color: var(--accent, #7c5cff);
|
|
4056
|
+
}
|
|
4057
|
+
.views-scope {
|
|
4058
|
+
font-family: var(--font-mono);
|
|
4059
|
+
font-size: 10.5px; color: var(--ink-muted);
|
|
4060
|
+
}
|
|
4061
|
+
.views-close {
|
|
4062
|
+
border: none; background: none; cursor: pointer;
|
|
4063
|
+
color: var(--ink-muted); font-size: 16px; line-height: 1; padding: 2px 4px;
|
|
4064
|
+
}
|
|
4065
|
+
.views-empty { margin: 6px 2px 4px; font-size: 12.5px; line-height: 1.5; color: var(--ink-muted); }
|
|
4066
|
+
.views-empty code { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); }
|
|
4067
|
+
.views-list { overflow-y: auto; margin: 0 -6px; padding: 0 6px; }
|
|
4068
|
+
.views-group + .views-group { margin-top: 10px; }
|
|
4069
|
+
.views-dateline {
|
|
4070
|
+
font-family: var(--font-mono);
|
|
4071
|
+
font-size: 10.5px; font-weight: 700; text-transform: uppercase;
|
|
4072
|
+
letter-spacing: 0.05em; color: var(--ink-muted);
|
|
4073
|
+
padding-bottom: 3px;
|
|
4074
|
+
}
|
|
4075
|
+
.views-row {
|
|
4076
|
+
display: flex; align-items: center; gap: 9px;
|
|
4077
|
+
text-decoration: none; color: inherit;
|
|
4078
|
+
padding: 6px; margin: 0 -6px; border-radius: 8px;
|
|
4079
|
+
}
|
|
4080
|
+
.views-row:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); }
|
|
4081
|
+
.views-kind {
|
|
4082
|
+
flex: 0 0 34px;
|
|
4083
|
+
font-family: var(--font-mono);
|
|
4084
|
+
font-size: 8.5px; font-weight: 700; letter-spacing: 0.04em;
|
|
4085
|
+
text-align: center;
|
|
4086
|
+
padding: 3px 0; border-radius: 4px;
|
|
4087
|
+
background: var(--bg-alt); color: var(--ink-muted);
|
|
4088
|
+
}
|
|
4089
|
+
.views-row.fresh .views-kind {
|
|
4090
|
+
background: color-mix(in srgb, var(--accent, #7c5cff) 20%, transparent);
|
|
4091
|
+
color: var(--accent, #7c5cff);
|
|
4092
|
+
}
|
|
4093
|
+
.views-row-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
|
|
4094
|
+
/* Titles are agent-written prose of arbitrary length; without the clamp one
|
|
4095
|
+
sentence reflowed the row to four lines and pushed the rest off-screen. */
|
|
4096
|
+
.views-title {
|
|
4097
|
+
font-size: 12.5px; font-weight: 600; color: var(--ink);
|
|
4098
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
4099
|
+
}
|
|
4100
|
+
.views-meta {
|
|
4101
|
+
font-size: 11px; color: var(--ink-muted);
|
|
4102
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
4103
|
+
}
|
|
4104
|
+
.views-dot {
|
|
4105
|
+
flex-shrink: 0;
|
|
4106
|
+
width: 6px; height: 6px; border-radius: 3px;
|
|
4107
|
+
background: var(--accent, #7c5cff);
|
|
4108
|
+
}
|
|
4109
|
+
|
|
4110
|
+
/* The affordances that open it: a topbar/switcher button and a card chip,
|
|
4111
|
+
each carrying the same unseen dot. */
|
|
4112
|
+
.views-chip-dot {
|
|
4113
|
+
flex-shrink: 0;
|
|
4114
|
+
width: 5px; height: 5px; border-radius: 3px;
|
|
4115
|
+
background: var(--accent, #7c5cff);
|
|
4116
|
+
}
|
|
4117
|
+
/* The switcher's header action is a fixed 32px square with a centred glyph —
|
|
4118
|
+
an inline dot would shove the icon off-centre, so it rides the corner. */
|
|
4119
|
+
.switcher-action { position: relative; }
|
|
4120
|
+
.switcher-action .views-chip-dot { position: absolute; top: 5px; right: 5px; }
|
|
4121
|
+
.switcher-chip.views {
|
|
4122
|
+
border: none;
|
|
4123
|
+
cursor: pointer;
|
|
4124
|
+
font-family: inherit;
|
|
4125
|
+
line-height: 1.2;
|
|
4126
|
+
}
|
|
4127
|
+
.switcher-chip.views.fresh {
|
|
4128
|
+
background: color-mix(in srgb, var(--accent, #7c5cff) 22%, transparent);
|
|
4129
|
+
color: var(--accent, #7c5cff);
|
|
4130
|
+
}
|
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
// Drag-and-drop
|
|
2
|
-
// real filesystem path
|
|
3
|
-
//
|
|
1
|
+
// Drag-and-drop helpers. Browsers deliberately never expose a dropped file's
|
|
2
|
+
// real filesystem path — so hop uploads the BYTES to the host instead and
|
|
3
|
+
// pastes the path the file landed on. The path hint below survives as the
|
|
4
|
+
// fallback for when that upload cannot happen (old daemon, offline).
|
|
5
|
+
|
|
6
|
+
/** Quote a path for a shell only when it needs it. A bare path stays bare —
|
|
7
|
+
* most drops land somewhere boring, and gratuitous quotes read as noise in
|
|
8
|
+
* an agent's composer. Single-quote style, with the standard '\'' escape. */
|
|
9
|
+
export const shellQuotePath = (filePath: string): string => {
|
|
10
|
+
if (filePath === "") return "''";
|
|
11
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(filePath)) return filePath;
|
|
12
|
+
return `'${filePath.replace(/'/g, `'\\''`)}'`;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Join uploaded paths into one paste: space-separated, each quoted only if
|
|
16
|
+
* it must be. Reads as a command argument list, which is what it usually
|
|
17
|
+
* becomes. */
|
|
18
|
+
export const pasteableUploadPaths = (paths: string[]): string =>
|
|
19
|
+
paths.map(shellQuotePath).join(" ");
|
|
4
20
|
|
|
5
21
|
/** OS-specific "copy the file's path" hotkey hint. Pass a platform string
|
|
6
22
|
* (navigator.platform + userAgent); pure so it's unit-testable. */
|
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
// long tail becomes compact rows auto-grouped by project directory. A filter
|
|
7
7
|
// query collapses everything into one flat ranked list.
|
|
8
8
|
|
|
9
|
+
// Per-session rollup of what `hop view` / `hop port` published, as
|
|
10
|
+
// /api/sessions reports it. latestAt is epoch SECONDS, matching the manifest.
|
|
11
|
+
export type ViewsSummary = {
|
|
12
|
+
count: number;
|
|
13
|
+
latestAt: number;
|
|
14
|
+
latestName?: string;
|
|
15
|
+
latestTitle?: string;
|
|
16
|
+
latestPath?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
9
19
|
export type SwitcherSession = {
|
|
10
20
|
name: string;
|
|
11
21
|
displayName: string;
|
|
@@ -40,6 +50,8 @@ export type SwitcherSession = {
|
|
|
40
50
|
// A hop CLI is attached in a real terminal window on the host. The wall
|
|
41
51
|
// must never resize such a session — that window's size is physical truth.
|
|
42
52
|
hasLocalCli?: boolean;
|
|
53
|
+
// Absent unless this session has published something.
|
|
54
|
+
views?: ViewsSummary;
|
|
43
55
|
};
|
|
44
56
|
|
|
45
57
|
export type SessionOriginScope = "user" | "agent" | "all";
|
|
@@ -326,6 +326,25 @@ describe("manual folders in the wall", () => {
|
|
|
326
326
|
expect(screen.getByText("Filed")).toBeTruthy();
|
|
327
327
|
});
|
|
328
328
|
|
|
329
|
+
it("Escape clears a typed filter before it dismisses the wall", () => {
|
|
330
|
+
const onClose = vi.fn();
|
|
331
|
+
render(<SessionSwitcher {...props} onClose={onClose} sessions={withFolder} folders={folders} open />);
|
|
332
|
+
const input = document.querySelector(".switcher-top input") as HTMLInputElement
|
|
333
|
+
|| document.querySelector("input") as HTMLInputElement;
|
|
334
|
+
fireEvent.change(input, { target: { value: "outsid" } });
|
|
335
|
+
expect(input.value).toBe("outsid");
|
|
336
|
+
|
|
337
|
+
// First Escape: the query is the innermost layer — it goes, the wall stays.
|
|
338
|
+
fireEvent.keyDown(window, { key: "Escape" });
|
|
339
|
+
expect(input.value).toBe("");
|
|
340
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
341
|
+
expect(screen.getByText("inside")).toBeTruthy(); // full wall is back
|
|
342
|
+
|
|
343
|
+
// Second Escape: nothing left to unwind, so the wall dismisses.
|
|
344
|
+
fireEvent.keyDown(window, { key: "Escape" });
|
|
345
|
+
expect(onClose).toHaveBeenCalled();
|
|
346
|
+
});
|
|
347
|
+
|
|
329
348
|
it("a query in manual mode still offers the full-history search", () => {
|
|
330
349
|
// Manual keeps its own render branch under a query (narrow-in-place), so
|
|
331
350
|
// the search tail — content hits + the deep button — must ride along or
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "../src/components/ViewsPanel";
|
|
4
|
+
import { SessionSwitcher } from "../src/components/SessionSwitcher";
|
|
5
|
+
import type { SwitcherSession } from "../src/utils/switcherModel";
|
|
6
|
+
|
|
7
|
+
// A slice of the real /api/views manifest: newest first, one empty title (the
|
|
8
|
+
// common case — `hop view` without --title), two sessions.
|
|
9
|
+
const items = [
|
|
10
|
+
{ session: "Orion", name: "agent-result.md", title: "Views end-to-end", path: "/view/Orion/agent-result.md/inline", bytes: 437, mtime: 1786303213 },
|
|
11
|
+
{ session: "Orion", name: "views-test.html", title: "", path: "/view/Orion/views-test.html/inline", bytes: 2062, mtime: 1786299563 },
|
|
12
|
+
{ session: "Nebula", name: "shot.png", title: "", path: "/view/Nebula/shot.png/inline", bytes: 999999, mtime: 1786299000 }
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
let store: Record<string, string> = {};
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
// jsdom's own localStorage is only partially implemented here, and the
|
|
18
|
+
// seen-markers are the whole point of these cases.
|
|
19
|
+
store = {};
|
|
20
|
+
vi.stubGlobal("localStorage", {
|
|
21
|
+
getItem: (key: string) => (key in store ? store[key] : null),
|
|
22
|
+
setItem: (key: string, value: string) => { store[key] = value; },
|
|
23
|
+
removeItem: (key: string) => { delete store[key]; },
|
|
24
|
+
clear: () => { store = {}; }
|
|
25
|
+
});
|
|
26
|
+
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ items }) })));
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => { cleanup(); vi.clearAllMocks(); vi.unstubAllGlobals(); });
|
|
29
|
+
|
|
30
|
+
describe("ViewsPanel", () => {
|
|
31
|
+
it("groups by session, leads with the title, falls back to the filename", async () => {
|
|
32
|
+
render(<ViewsPanel sessions={[{ name: "Orion", displayName: "orion-worker", internalName: "Orion" }]} onClose={() => {}} />);
|
|
33
|
+
await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
|
|
34
|
+
// The manifest only knows internalNames; the dateline shows the rename.
|
|
35
|
+
expect(screen.getByText("orion-worker")).toBeTruthy();
|
|
36
|
+
expect(screen.getByText("Nebula")).toBeTruthy();
|
|
37
|
+
expect(screen.getByText("views-test.html")).toBeTruthy();
|
|
38
|
+
expect(screen.getByText("MD")).toBeTruthy();
|
|
39
|
+
expect(screen.getByText("IMG")).toBeTruthy();
|
|
40
|
+
|
|
41
|
+
const link = screen.getByText("Views end-to-end").closest("a") as HTMLAnchorElement;
|
|
42
|
+
expect(link.getAttribute("href")).toBe("/view/Orion/agent-result.md/inline");
|
|
43
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
44
|
+
expect(link.className).toContain("fresh");
|
|
45
|
+
|
|
46
|
+
// Opening IS seeing: every session on screen gets its high-water mark.
|
|
47
|
+
await waitFor(() => expect(loadViewsSeen()).toEqual({ Orion: 1786303213, Nebula: 1786299000 }));
|
|
48
|
+
expect(hasUnseenViews("Orion", 1786303213, loadViewsSeen())).toBe(false);
|
|
49
|
+
expect(hasUnseenViews("Orion", 1786303300, loadViewsSeen())).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("scopes to one session without pruning the others' markers", async () => {
|
|
53
|
+
store["hop_views_seen"] = JSON.stringify({ Nebula: 1 });
|
|
54
|
+
render(<ViewsPanel session="orion" onClose={() => {}} />);
|
|
55
|
+
await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
|
|
56
|
+
expect(screen.queryByText("shot.png")).toBeNull();
|
|
57
|
+
await waitFor(() => expect(loadViewsSeen().Orion).toBe(1786303213));
|
|
58
|
+
expect(loadViewsSeen().Nebula).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("explains the feature when nothing is published", async () => {
|
|
62
|
+
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ items: [] }) })));
|
|
63
|
+
render(<ViewsPanel onClose={() => {}} />);
|
|
64
|
+
await waitFor(() => expect(screen.getByText(/Nothing published yet/)).toBeTruthy());
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("closes on Escape and on the backdrop", async () => {
|
|
68
|
+
const onClose = vi.fn();
|
|
69
|
+
const { container } = render(<ViewsPanel onClose={onClose} />);
|
|
70
|
+
await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
|
|
71
|
+
fireEvent.keyDown(window, { key: "Escape" });
|
|
72
|
+
expect(onClose).toHaveBeenCalled();
|
|
73
|
+
fireEvent.click(container.querySelector(".views-backdrop")!);
|
|
74
|
+
expect(onClose.mock.calls.length).toBe(2);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const switcherSessions: SwitcherSession[] = [
|
|
79
|
+
{
|
|
80
|
+
name: "Orion", displayName: "Orion", internalName: "Orion",
|
|
81
|
+
active: true, starting: false, createdBy: "user",
|
|
82
|
+
views: { count: 3, latestAt: 1786303213, latestName: "agent-result.md", latestTitle: "Views end-to-end", latestPath: "/view/Orion/agent-result.md/inline" }
|
|
83
|
+
},
|
|
84
|
+
{ name: "Nebula", displayName: "Nebula", internalName: "Nebula", active: true, starting: false, createdBy: "user" }
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
describe("switcher views affordances", () => {
|
|
88
|
+
const onOpenViews = vi.fn();
|
|
89
|
+
const props = {
|
|
90
|
+
sessions: switcherSessions, currentRoom: null,
|
|
91
|
+
onClose: vi.fn(), onSwitch: vi.fn(), onRefresh: vi.fn(), onNotice: vi.fn(), onOpenViews
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
sessionStorage.clear();
|
|
96
|
+
Object.defineProperty(HTMLElement.prototype, "scrollTo", { configurable: true, value: vi.fn() });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("opens scoped from a card chip and fleet-wide from the header", () => {
|
|
100
|
+
render(<SessionSwitcher {...props} open />);
|
|
101
|
+
const chip = screen.getByRole("button", { name: "3 published views in Orion" });
|
|
102
|
+
expect(chip.className).toContain("fresh"); // no marker yet = unseen
|
|
103
|
+
fireEvent.click(chip);
|
|
104
|
+
expect(onOpenViews).toHaveBeenCalledWith("Orion");
|
|
105
|
+
|
|
106
|
+
fireEvent.click(screen.getByRole("button", { name: "Published views" }));
|
|
107
|
+
expect(onOpenViews).toHaveBeenLastCalledWith(); // no argument = the fleet
|
|
108
|
+
expect(screen.queryByRole("button", { name: /published views in Nebula/ })).toBeNull();
|
|
109
|
+
expect(screen.getByLabelText("New views")).toBeTruthy();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("goes quiet once the marker covers the newest view", () => {
|
|
113
|
+
store["hop_views_seen"] = JSON.stringify({ Orion: 1786303213 });
|
|
114
|
+
render(<SessionSwitcher {...props} open />);
|
|
115
|
+
expect(screen.getByRole("button", { name: "3 published views in Orion" }).className).not.toContain("fresh");
|
|
116
|
+
expect(screen.queryByLabelText("New views")).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { originalPathHint } from "../src/utils/fileDrop";
|
|
2
|
+
import { originalPathHint, pasteableUploadPaths, shellQuotePath } from "../src/utils/fileDrop";
|
|
3
|
+
|
|
4
|
+
describe("shellQuotePath", () => {
|
|
5
|
+
it("leaves an ordinary path bare", () => {
|
|
6
|
+
expect(shellQuotePath("/Users/me/.hop2/uploads/room/shot.png")).toBe("/Users/me/.hop2/uploads/room/shot.png");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("quotes a path containing spaces", () => {
|
|
10
|
+
expect(shellQuotePath("/tmp/my file.txt")).toBe("'/tmp/my file.txt'");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("survives an apostrophe in the filename", () => {
|
|
14
|
+
// The classic shell-quoting trap: 'it's' must close, escape, reopen.
|
|
15
|
+
expect(shellQuotePath("/tmp/it's here.txt")).toBe(`'/tmp/it'\\''s here.txt'`);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("quotes shell metacharacters that would otherwise execute", () => {
|
|
19
|
+
expect(shellQuotePath("/tmp/$(whoami).txt")).toBe("'/tmp/$(whoami).txt'");
|
|
20
|
+
expect(shellQuotePath("/tmp/a;rm -rf b")).toBe("'/tmp/a;rm -rf b'");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("joins several uploads into one argument list", () => {
|
|
24
|
+
expect(pasteableUploadPaths(["/tmp/a.png", "/tmp/b c.png"])).toBe("/tmp/a.png '/tmp/b c.png'");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
3
27
|
|
|
4
28
|
describe("originalPathHint", () => {
|
|
5
29
|
it("names ⌘⌥C and Finder on Apple platforms", () => {
|