paseo-open-with 0.1.0 → 0.2.0
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/OVERVIEW.md +23 -2
- package/OVERVIEW.zh-CN.md +23 -2
- package/README.md +104 -14
- package/client/file-links.ts +527 -0
- package/client/menu.tsx +3 -0
- package/client/messages.ts +16 -2
- package/docs/images/file-menu-zh-CN.png +0 -0
- package/docs/images/file-menu.png +0 -0
- package/docs/images/menu.png +0 -0
- package/docs/images/promo.png +0 -0
- package/docs/images/settings.png +0 -0
- package/index.client.tsx +3 -0
- package/index.server.ts +6 -1
- package/package.json +7 -3
- package/paseo-plugin.json +11 -3
- package/server/apps.ts +18 -0
- package/server/file-links.ts +191 -0
- package/server/open-with.ts +29 -12
- package/shared/file-links.ts +109 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
fileLinkRpc,
|
|
5
|
+
getRouteContext,
|
|
6
|
+
parseLocalHref,
|
|
7
|
+
type FileLinkAction,
|
|
8
|
+
type FileLinkFailure,
|
|
9
|
+
} from "../shared/file-links";
|
|
10
|
+
import type { App } from "../shared/open-with";
|
|
11
|
+
import { SETTINGS_ID, type OpenWithMenu } from "./menu";
|
|
12
|
+
import { messages } from "./messages";
|
|
13
|
+
|
|
14
|
+
// This plugin typechecks without the DOM library. Declare only what this module uses.
|
|
15
|
+
interface Style {
|
|
16
|
+
[property: string]: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Element {
|
|
20
|
+
readonly style: Style;
|
|
21
|
+
readonly parentElement: Element | null;
|
|
22
|
+
readonly offsetWidth: number;
|
|
23
|
+
readonly offsetHeight: number;
|
|
24
|
+
textContent: string | null;
|
|
25
|
+
closest(selector: string): Element | null;
|
|
26
|
+
contains(other: unknown): boolean;
|
|
27
|
+
getBoundingClientRect(): { readonly left: number; readonly right: number; readonly top: number };
|
|
28
|
+
getAttribute(name: string): string | null;
|
|
29
|
+
setAttribute(name: string, value: string): void;
|
|
30
|
+
appendChild(child: Element): void;
|
|
31
|
+
replaceChildren(...children: Element[]): void;
|
|
32
|
+
remove(): void;
|
|
33
|
+
addEventListener(type: string, listener: (event: DomEvent) => void): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface DomEvent {
|
|
37
|
+
readonly target: unknown;
|
|
38
|
+
readonly key?: string;
|
|
39
|
+
readonly clientX: number;
|
|
40
|
+
readonly clientY: number;
|
|
41
|
+
readonly altKey: boolean;
|
|
42
|
+
readonly ctrlKey: boolean;
|
|
43
|
+
readonly metaKey: boolean;
|
|
44
|
+
readonly shiftKey: boolean;
|
|
45
|
+
preventDefault(): void;
|
|
46
|
+
stopPropagation(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ComputedStyle {
|
|
50
|
+
readonly color: string;
|
|
51
|
+
readonly backgroundColor: string;
|
|
52
|
+
readonly fontFamily: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ImageElement extends Element {
|
|
56
|
+
readonly naturalWidth: number;
|
|
57
|
+
readonly naturalHeight: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Canvas {
|
|
61
|
+
width: number;
|
|
62
|
+
height: number;
|
|
63
|
+
getContext(type: "2d"): { drawImage(image: ImageElement, x: number, y: number): void } | null;
|
|
64
|
+
toBlob(callback: (blob: Blob | null) => void, type: string): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface Blob {
|
|
68
|
+
readonly type: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
declare class ClipboardItem {
|
|
72
|
+
constructor(items: Record<string, Promise<Blob>>);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
declare const document: {
|
|
76
|
+
readonly body: Element;
|
|
77
|
+
createElement(tag: "canvas"): Canvas;
|
|
78
|
+
createElement(tag: string): Element;
|
|
79
|
+
addEventListener(type: string, listener: (event: DomEvent) => void, capture: boolean): void;
|
|
80
|
+
removeEventListener(type: string, listener: (event: DomEvent) => void, capture: boolean): void;
|
|
81
|
+
};
|
|
82
|
+
declare const window: {
|
|
83
|
+
readonly innerWidth: number;
|
|
84
|
+
readonly innerHeight: number;
|
|
85
|
+
getComputedStyle(element: Element): ComputedStyle;
|
|
86
|
+
addEventListener(type: string, listener: () => void): void;
|
|
87
|
+
removeEventListener(type: string, listener: () => void): void;
|
|
88
|
+
};
|
|
89
|
+
declare const location: { readonly pathname: string };
|
|
90
|
+
declare const navigator: {
|
|
91
|
+
readonly clipboard: {
|
|
92
|
+
writeText(text: string): Promise<void>;
|
|
93
|
+
write(items: ClipboardItem[]): Promise<void>;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
declare function setTimeout(callback: () => void, ms: number): unknown;
|
|
97
|
+
|
|
98
|
+
type Resolution =
|
|
99
|
+
| { ok: true; path: string; isDirectory: boolean }
|
|
100
|
+
| { ok: false; reason: FileLinkFailure; path?: string; message?: string };
|
|
101
|
+
|
|
102
|
+
const EDGE = 8;
|
|
103
|
+
const ERROR_MS = 2000;
|
|
104
|
+
|
|
105
|
+
// The Lucide icons that the built-in apps use. Other names get AppWindow.
|
|
106
|
+
const LUCIDE: Record<string, string> = {
|
|
107
|
+
GitFork:
|
|
108
|
+
'<circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9"/><path d="M12 12v3"/>',
|
|
109
|
+
Github:
|
|
110
|
+
'<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>',
|
|
111
|
+
Code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
|
|
112
|
+
MousePointer2:
|
|
113
|
+
'<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"/>',
|
|
114
|
+
SquareTerminal: '<path d="m7 11 2-2-2-2"/><path d="M11 13h4"/><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>',
|
|
115
|
+
AppWindow: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M10 4v4"/><path d="M2 8h20"/><path d="M6 4v4"/>',
|
|
116
|
+
Settings2:
|
|
117
|
+
'<path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/>',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function lucideImage(name: string, color: string): string {
|
|
121
|
+
const svg =
|
|
122
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-opacity="0.7" ` +
|
|
123
|
+
`stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${LUCIDE[name] ?? LUCIDE.AppWindow}</svg>`;
|
|
124
|
+
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function asElement(value: unknown): Element | null {
|
|
128
|
+
return value && typeof value === "object" && "closest" in value ? (value as Element) : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface MenuTarget {
|
|
132
|
+
element: Element;
|
|
133
|
+
/** Local file source, or null when the target has no file on disk. */
|
|
134
|
+
href: string | null;
|
|
135
|
+
image: ImageElement | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Paseo shows markdown images from preview URLs and keeps the original sources here.
|
|
139
|
+
const IMAGE_SOURCES_ATTRIBUTE = "data-paseo-markdown-image-sources";
|
|
140
|
+
|
|
141
|
+
function findImage(target: Element): MenuTarget | null {
|
|
142
|
+
const image = target.closest("img") as ImageElement | null;
|
|
143
|
+
const sources = image?.closest(`[${IMAGE_SOURCES_ATTRIBUTE}]`)?.getAttribute(IMAGE_SOURCES_ATTRIBUTE);
|
|
144
|
+
if (!image || !sources) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const src = image.getAttribute("src") ?? "";
|
|
148
|
+
let source = src;
|
|
149
|
+
try {
|
|
150
|
+
source = (JSON.parse(sources) as Record<string, string>)[src] ?? src;
|
|
151
|
+
} catch {
|
|
152
|
+
// Keep the displayed URL.
|
|
153
|
+
}
|
|
154
|
+
return { element: image, href: parseLocalHref(source) ? source : null, image };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Assistant markdown links open in a new target; app navigation links do not. */
|
|
158
|
+
function findLink(target: Element): MenuTarget | null {
|
|
159
|
+
const anchor = target.closest('a[href][target="_blank"]');
|
|
160
|
+
const href = anchor?.getAttribute("href") ?? "";
|
|
161
|
+
if (!anchor || !parseLocalHref(href)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return { element: anchor, href, image: null };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function findTarget(event: DomEvent): MenuTarget | null {
|
|
168
|
+
const target = asElement(event.target);
|
|
169
|
+
return target ? (findImage(target) ?? findLink(target)) : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function imageToPng(image: ImageElement): Promise<Blob> {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const canvas = document.createElement("canvas");
|
|
175
|
+
canvas.width = image.naturalWidth;
|
|
176
|
+
canvas.height = image.naturalHeight;
|
|
177
|
+
const context = canvas.getContext("2d");
|
|
178
|
+
if (!context) {
|
|
179
|
+
reject(new Error("No 2D canvas"));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
context.drawImage(image, 0, 0);
|
|
183
|
+
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("No PNG"))), "image/png");
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isTransparent(color: string): boolean {
|
|
188
|
+
return color === "transparent" || /rgba\(.*,\s*0\)$/.test(color);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function luminance(color: string): number | null {
|
|
192
|
+
const channels = color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
|
193
|
+
if (!channels || channels.length < 3) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const [r, g, b] = channels.map((value) => {
|
|
197
|
+
const c = value / 255;
|
|
198
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
199
|
+
});
|
|
200
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function contrast(a: string, b: string): number {
|
|
204
|
+
const la = luminance(a);
|
|
205
|
+
const lb = luminance(b);
|
|
206
|
+
if (la === null || lb === null) {
|
|
207
|
+
return 21;
|
|
208
|
+
}
|
|
209
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function themeFrom(anchor: Element) {
|
|
213
|
+
const text = anchor.parentElement?.parentElement ?? anchor;
|
|
214
|
+
const textStyle = window.getComputedStyle(text);
|
|
215
|
+
let background = "rgb(32, 32, 32)";
|
|
216
|
+
for (let node: Element | null = anchor; node; node = node.parentElement) {
|
|
217
|
+
const color = window.getComputedStyle(node).backgroundColor;
|
|
218
|
+
if (!isTransparent(color)) {
|
|
219
|
+
background = color;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// An image has no text color of its own, so its inherited color can be unreadable.
|
|
224
|
+
let foreground = textStyle.color;
|
|
225
|
+
if (contrast(foreground, background) < 4.5) {
|
|
226
|
+
foreground = (luminance(background) ?? 0) < 0.5 ? "rgb(236, 236, 236)" : "rgb(28, 28, 28)";
|
|
227
|
+
}
|
|
228
|
+
return { foreground, background, fontFamily: textStyle.fontFamily };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function installFileLinkMenu(client: PluginClientContext, openWith: OpenWithMenu): () => void {
|
|
232
|
+
if (Platform.OS !== "web") {
|
|
233
|
+
return () => {};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let closeMenu: (() => void) | null = null;
|
|
237
|
+
type Key = "reveal" | "open" | "openWith" | "copyPath" | "copyImage" | "fileNotFound" | "cannotOpen" | "noFolder" | "edit";
|
|
238
|
+
const t = (key: Key) => client.i18n.t(messages, key);
|
|
239
|
+
|
|
240
|
+
const failureText = (reason: FileLinkFailure, app?: App) => {
|
|
241
|
+
if (app && reason === "not-installed") return client.i18n.t(messages, "notInstalled", { name: app.name });
|
|
242
|
+
if (app && reason !== "not-found") return client.i18n.t(messages, "failed", { name: app.name });
|
|
243
|
+
return reason === "not-found" ? t("fileNotFound") : reason === "no-base" ? t("noFolder") : t("cannotOpen");
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const showMenu = (event: DomEvent, { element, href, image }: MenuTarget) => {
|
|
247
|
+
closeMenu?.();
|
|
248
|
+
const { agentId, workspaceId } = getRouteContext(location.pathname);
|
|
249
|
+
const route = { agentId, workspaceId };
|
|
250
|
+
const call = (action: FileLinkAction, appId?: string): Promise<Resolution> =>
|
|
251
|
+
href
|
|
252
|
+
? client
|
|
253
|
+
.rpc(fileLinkRpc, { href, action, appId, ...route })
|
|
254
|
+
.catch((): Resolution => ({ ok: false, reason: "failed" }))
|
|
255
|
+
: Promise.resolve({ ok: false, reason: "invalid" });
|
|
256
|
+
const resolution = call("resolve");
|
|
257
|
+
|
|
258
|
+
const theme = themeFrom(element);
|
|
259
|
+
const hover = `color-mix(in srgb, ${theme.foreground} 10%, transparent)`;
|
|
260
|
+
const panel = () => {
|
|
261
|
+
const box = document.createElement("div");
|
|
262
|
+
box.setAttribute("role", "menu");
|
|
263
|
+
Object.assign(box.style, {
|
|
264
|
+
position: "fixed",
|
|
265
|
+
zIndex: "2147483647",
|
|
266
|
+
minWidth: "160px",
|
|
267
|
+
padding: "4px",
|
|
268
|
+
borderRadius: "8px",
|
|
269
|
+
border: `1px solid color-mix(in srgb, ${theme.foreground} 16%, transparent)`,
|
|
270
|
+
background: theme.background,
|
|
271
|
+
color: theme.foreground,
|
|
272
|
+
fontFamily: theme.fontFamily,
|
|
273
|
+
fontSize: "13px",
|
|
274
|
+
lineHeight: "18px",
|
|
275
|
+
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.28)",
|
|
276
|
+
userSelect: "none",
|
|
277
|
+
left: "0px",
|
|
278
|
+
top: "0px",
|
|
279
|
+
visibility: "hidden",
|
|
280
|
+
});
|
|
281
|
+
return box;
|
|
282
|
+
};
|
|
283
|
+
const place = (box: Element, left: number, top: number) => {
|
|
284
|
+
box.style.left = `${Math.max(EDGE, Math.min(left, window.innerWidth - box.offsetWidth - EDGE))}px`;
|
|
285
|
+
box.style.top = `${Math.max(EDGE, Math.min(top, window.innerHeight - box.offsetHeight - EDGE))}px`;
|
|
286
|
+
box.style.visibility = "visible";
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const menu = panel();
|
|
290
|
+
let submenu: Element | null = null;
|
|
291
|
+
const closeSubmenu = () => {
|
|
292
|
+
submenu?.remove();
|
|
293
|
+
submenu = null;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const row = (label: string, icon?: string) => {
|
|
297
|
+
const item = document.createElement("div");
|
|
298
|
+
Object.assign(item.style, {
|
|
299
|
+
display: "flex",
|
|
300
|
+
alignItems: "center",
|
|
301
|
+
gap: "8px",
|
|
302
|
+
padding: "6px 10px",
|
|
303
|
+
borderRadius: "5px",
|
|
304
|
+
whiteSpace: "nowrap",
|
|
305
|
+
});
|
|
306
|
+
if (icon) {
|
|
307
|
+
const image = document.createElement("img");
|
|
308
|
+
image.setAttribute("src", icon);
|
|
309
|
+
Object.assign(image.style, { width: "16px", height: "16px", flex: "none" });
|
|
310
|
+
item.appendChild(image);
|
|
311
|
+
}
|
|
312
|
+
const text = document.createElement("span");
|
|
313
|
+
text.textContent = label;
|
|
314
|
+
item.appendChild(text);
|
|
315
|
+
return item;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const separator = () => {
|
|
319
|
+
const line = document.createElement("div");
|
|
320
|
+
Object.assign(line.style, {
|
|
321
|
+
height: "1px",
|
|
322
|
+
margin: "4px 6px",
|
|
323
|
+
background: `color-mix(in srgb, ${theme.foreground} 16%, transparent)`,
|
|
324
|
+
});
|
|
325
|
+
return line;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const showError = (text: string) => {
|
|
329
|
+
closeSubmenu();
|
|
330
|
+
const item = row(text);
|
|
331
|
+
item.style.opacity = "0.7";
|
|
332
|
+
menu.replaceChildren(item);
|
|
333
|
+
setTimeout(close, ERROR_MS);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const highlight = (item: Element) => {
|
|
337
|
+
item.addEventListener("mouseenter", () => {
|
|
338
|
+
item.style.background = hover;
|
|
339
|
+
});
|
|
340
|
+
item.addEventListener("mouseleave", () => {
|
|
341
|
+
item.style.background = "transparent";
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const button = (label: string, run: () => Promise<void>, icon?: string) => {
|
|
346
|
+
const item = row(label, icon);
|
|
347
|
+
item.setAttribute("role", "menuitem");
|
|
348
|
+
item.style.cursor = "pointer";
|
|
349
|
+
highlight(item);
|
|
350
|
+
item.addEventListener("mouseenter", () => {
|
|
351
|
+
if (item.parentElement === menu) closeSubmenu();
|
|
352
|
+
});
|
|
353
|
+
item.addEventListener("click", (clickEvent) => {
|
|
354
|
+
clickEvent.stopPropagation();
|
|
355
|
+
void run();
|
|
356
|
+
});
|
|
357
|
+
return item;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const act = (action: "reveal" | "open" | "app", app?: App) => async () => {
|
|
361
|
+
const result = await call(action, app?.id);
|
|
362
|
+
if (result.ok) {
|
|
363
|
+
close();
|
|
364
|
+
} else {
|
|
365
|
+
showError(failureText(result.reason, app));
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const copy = async (write: () => Promise<void>) => {
|
|
370
|
+
try {
|
|
371
|
+
await write();
|
|
372
|
+
close();
|
|
373
|
+
} catch {
|
|
374
|
+
showError(t("cannotOpen"));
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const copyPath = async () => {
|
|
379
|
+
const result = await resolution;
|
|
380
|
+
const local = parseLocalHref(href ?? "");
|
|
381
|
+
const path = result.path ?? (local?.kind === "path" ? local.path : (href ?? ""));
|
|
382
|
+
await copy(() => navigator.clipboard.writeText(path));
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// Pass the PNG as a promise so the write starts inside the click.
|
|
386
|
+
const copyImage = (target: ImageElement) => () =>
|
|
387
|
+
copy(() => navigator.clipboard.write([new ClipboardItem({ "image/png": imageToPng(target) })]));
|
|
388
|
+
|
|
389
|
+
// The file manager is already in the menu as Show in Folder.
|
|
390
|
+
const apps = openWith.apps().filter((app) => app.enabled && app.command && app.preset !== "file-manager");
|
|
391
|
+
|
|
392
|
+
const openSubmenu = (anchor: Element) => {
|
|
393
|
+
if (submenu) return;
|
|
394
|
+
const box = panel();
|
|
395
|
+
for (const app of apps) {
|
|
396
|
+
box.appendChild(button(app.name, act("app", app), app.image ?? lucideImage(app.icon, theme.foreground)));
|
|
397
|
+
}
|
|
398
|
+
box.appendChild(separator());
|
|
399
|
+
const edit = async () => {
|
|
400
|
+
close();
|
|
401
|
+
client.openSettings(SETTINGS_ID);
|
|
402
|
+
};
|
|
403
|
+
box.appendChild(button(t("edit"), edit, lucideImage("Settings2", theme.foreground)));
|
|
404
|
+
document.body.appendChild(box);
|
|
405
|
+
submenu = box;
|
|
406
|
+
const edges = menu.getBoundingClientRect();
|
|
407
|
+
const right = edges.right + 2;
|
|
408
|
+
const left = right + box.offsetWidth > window.innerWidth - EDGE ? edges.left - box.offsetWidth - 2 : right;
|
|
409
|
+
// Line up the first app with the Open With row.
|
|
410
|
+
place(box, left, anchor.getBoundingClientRect().top - 5);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const openWithRow = (() => {
|
|
414
|
+
if (!href || apps.length === 0) return null;
|
|
415
|
+
const item = row(t("openWith"));
|
|
416
|
+
const arrow = document.createElement("span");
|
|
417
|
+
arrow.textContent = "›";
|
|
418
|
+
Object.assign(arrow.style, { marginLeft: "auto", paddingLeft: "16px", opacity: "0.6" });
|
|
419
|
+
item.appendChild(arrow);
|
|
420
|
+
item.setAttribute("role", "menuitem");
|
|
421
|
+
item.setAttribute("aria-haspopup", "menu");
|
|
422
|
+
item.style.cursor = "default";
|
|
423
|
+
highlight(item);
|
|
424
|
+
item.addEventListener("mouseenter", () => openSubmenu(item));
|
|
425
|
+
item.addEventListener("click", (clickEvent) => {
|
|
426
|
+
clickEvent.stopPropagation();
|
|
427
|
+
openSubmenu(item);
|
|
428
|
+
});
|
|
429
|
+
return item;
|
|
430
|
+
})();
|
|
431
|
+
|
|
432
|
+
const fileActions = href
|
|
433
|
+
? [button(t("reveal"), act("reveal")), button(t("open"), act("open")), ...(openWithRow ? [openWithRow] : [])]
|
|
434
|
+
: [];
|
|
435
|
+
const fileItems = href ? [...fileActions, button(t("copyPath"), copyPath)] : [];
|
|
436
|
+
const items = image ? [...fileItems, button(t("copyImage"), copyImage(image))] : fileItems;
|
|
437
|
+
for (const item of items) {
|
|
438
|
+
menu.appendChild(item);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
void resolution.then((result) => {
|
|
442
|
+
if (!href || result.ok || closeMenu !== close) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
closeSubmenu();
|
|
446
|
+
for (const item of fileActions) {
|
|
447
|
+
item.style.opacity = "0.4";
|
|
448
|
+
item.style.pointerEvents = "none";
|
|
449
|
+
}
|
|
450
|
+
const note = row(failureText(result.reason));
|
|
451
|
+
note.style.opacity = "0.7";
|
|
452
|
+
menu.replaceChildren(note, ...items);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
document.body.appendChild(menu);
|
|
456
|
+
place(menu, event.clientX, event.clientY);
|
|
457
|
+
|
|
458
|
+
const onPointerDown = (downEvent: DomEvent) => {
|
|
459
|
+
if (!menu.contains(downEvent.target) && !submenu?.contains(downEvent.target)) {
|
|
460
|
+
close();
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
const onKeyDown = (keyEvent: DomEvent) => {
|
|
464
|
+
if (keyEvent.key === "Escape") {
|
|
465
|
+
close();
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
const onViewportChange = () => close();
|
|
469
|
+
|
|
470
|
+
function close() {
|
|
471
|
+
if (closeMenu !== close) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
closeMenu = null;
|
|
475
|
+
closeSubmenu();
|
|
476
|
+
menu.remove();
|
|
477
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
478
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
479
|
+
document.removeEventListener("wheel", onViewportChange, true);
|
|
480
|
+
window.removeEventListener("resize", onViewportChange);
|
|
481
|
+
window.removeEventListener("blur", onViewportChange);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
485
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
486
|
+
document.addEventListener("wheel", onViewportChange, true);
|
|
487
|
+
window.addEventListener("resize", onViewportChange);
|
|
488
|
+
window.addEventListener("blur", onViewportChange);
|
|
489
|
+
closeMenu = close;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// The app loads this plugin once per connected host, and every copy sees the same
|
|
493
|
+
// right-click. Only the copy whose host owns the open page may answer.
|
|
494
|
+
// undefined: not known yet. null: the host did not say, so answer every route.
|
|
495
|
+
let ownServerId: string | null | undefined;
|
|
496
|
+
void client.paseo.hosts.list().then(
|
|
497
|
+
(directory) => {
|
|
498
|
+
ownServerId = directory.self.serverId;
|
|
499
|
+
},
|
|
500
|
+
() => {
|
|
501
|
+
ownServerId = null;
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const onContextMenu = (event: DomEvent) => {
|
|
506
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const { serverId } = getRouteContext(location.pathname);
|
|
510
|
+
if (serverId && ownServerId !== null && serverId !== ownServerId) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const target = findTarget(event);
|
|
514
|
+
if (!target) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
event.stopPropagation();
|
|
519
|
+
showMenu(event, target);
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
document.addEventListener("contextmenu", onContextMenu, true);
|
|
523
|
+
return () => {
|
|
524
|
+
document.removeEventListener("contextmenu", onContextMenu, true);
|
|
525
|
+
closeMenu?.();
|
|
526
|
+
};
|
|
527
|
+
}
|
package/client/menu.tsx
CHANGED
|
@@ -26,6 +26,8 @@ function imageIcon(uri: string): ComponentType<PluginButtonIconProps> {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface OpenWithMenu {
|
|
29
|
+
/** The apps from the last read. */
|
|
30
|
+
apps(): readonly App[];
|
|
29
31
|
/** Reads the app list again and rebuilds every menu. */
|
|
30
32
|
refresh(): Promise<void>;
|
|
31
33
|
dispose(): void;
|
|
@@ -122,6 +124,7 @@ export function installMenu(client: PluginClientContext): OpenWithMenu {
|
|
|
122
124
|
const timer = setInterval(() => void refresh(), REFRESH_MS);
|
|
123
125
|
|
|
124
126
|
return {
|
|
127
|
+
apps: () => apps,
|
|
125
128
|
refresh,
|
|
126
129
|
dispose() {
|
|
127
130
|
disposed = true;
|
package/client/messages.ts
CHANGED
|
@@ -15,7 +15,7 @@ export const messages = defineTranslations({
|
|
|
15
15
|
name: "Name",
|
|
16
16
|
command: "Program",
|
|
17
17
|
args: "Arguments",
|
|
18
|
-
argsHint: "{path} is the workspace folder. Use double quotes around values with spaces.",
|
|
18
|
+
argsHint: "{path} is the workspace folder, or the file you right-clicked. Use double quotes around values with spaces.",
|
|
19
19
|
icon: "Icon",
|
|
20
20
|
iconHint: "A Lucide icon name. The program icon is used when Paseo can read it.",
|
|
21
21
|
add: "Add app",
|
|
@@ -30,6 +30,13 @@ export const messages = defineTranslations({
|
|
|
30
30
|
restoreDefaults: "Restore defaults",
|
|
31
31
|
newApp: "New app",
|
|
32
32
|
commandRequired: "Enter a program for each app you added.",
|
|
33
|
+
reveal: "Show in Folder",
|
|
34
|
+
open: "Open",
|
|
35
|
+
openWith: "Open With",
|
|
36
|
+
copyPath: "Copy Path",
|
|
37
|
+
copyImage: "Copy Image",
|
|
38
|
+
fileNotFound: "File not found",
|
|
39
|
+
cannotOpen: "Could not open",
|
|
33
40
|
},
|
|
34
41
|
"zh-CN": {
|
|
35
42
|
button: "用其他程序打开",
|
|
@@ -44,7 +51,7 @@ export const messages = defineTranslations({
|
|
|
44
51
|
name: "名称",
|
|
45
52
|
command: "程序",
|
|
46
53
|
args: "参数",
|
|
47
|
-
argsHint: "{path} 代表 workspace
|
|
54
|
+
argsHint: "{path} 代表 workspace 目录,或你右击的文件。值里有空格时,用双引号括起来。",
|
|
48
55
|
icon: "图标",
|
|
49
56
|
iconHint: "Lucide 图标名。能读到程序图标时,用程序图标。",
|
|
50
57
|
add: "添加程序",
|
|
@@ -59,6 +66,13 @@ export const messages = defineTranslations({
|
|
|
59
66
|
restoreDefaults: "恢复默认设置",
|
|
60
67
|
newApp: "新程序",
|
|
61
68
|
commandRequired: "请给每个添加的程序填写程序路径。",
|
|
69
|
+
reveal: "在文件夹中显示",
|
|
70
|
+
open: "打开",
|
|
71
|
+
openWith: "打开方式",
|
|
72
|
+
copyPath: "复制路径",
|
|
73
|
+
copyImage: "复制图片",
|
|
74
|
+
fileNotFound: "找不到文件",
|
|
75
|
+
cannotOpen: "无法打开",
|
|
62
76
|
},
|
|
63
77
|
},
|
|
64
78
|
});
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/index.client.tsx
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { ScrollView } from "react-native";
|
|
2
2
|
import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { installFileLinkMenu } from "./client/file-links";
|
|
3
4
|
import { installMenu, SETTINGS_ID } from "./client/menu";
|
|
4
5
|
import { messages } from "./client/messages";
|
|
5
6
|
import { SettingsScreen } from "./client/settings";
|
|
6
7
|
|
|
7
8
|
export default function contribute(client: PluginClientContext) {
|
|
8
9
|
const menu = installMenu(client);
|
|
10
|
+
const removeFileLinkMenu = installFileLinkMenu(client, menu);
|
|
9
11
|
const Settings = (props: PluginSurfaceProps) => (
|
|
10
12
|
<ScrollView contentContainerStyle={{ padding: 18, maxWidth: 720 }}>
|
|
11
13
|
<SettingsScreen {...props} onSaved={() => void menu.refresh()} />
|
|
@@ -19,6 +21,7 @@ export default function contribute(client: PluginClientContext) {
|
|
|
19
21
|
});
|
|
20
22
|
return () => {
|
|
21
23
|
removeSettings();
|
|
24
|
+
removeFileLinkMenu();
|
|
22
25
|
menu.dispose();
|
|
23
26
|
};
|
|
24
27
|
}
|
package/index.server.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
2
|
import { appsRpc, launchRpc, openWithSettings } from "./shared/open-with";
|
|
3
|
-
import {
|
|
3
|
+
import { fileLinkRpc } from "./shared/file-links";
|
|
4
|
+
import { launchApp, listApps, openInApp } from "./server/open-with";
|
|
5
|
+
import { actOnFileLink } from "./server/file-links";
|
|
4
6
|
|
|
5
7
|
export default function contribute(server: PluginServerContext) {
|
|
6
8
|
const settings = server.registerSettings(openWithSettings);
|
|
7
9
|
server.handle(appsRpc, () => listApps(settings));
|
|
8
10
|
server.handle(launchRpc, (input, { paseo }) => launchApp(input, settings, paseo));
|
|
11
|
+
server.handle(fileLinkRpc, (input, { paseo }) =>
|
|
12
|
+
actOnFileLink(input, paseo, (appId, path, isDirectory) => openInApp(appId, path, isDirectory, settings)),
|
|
13
|
+
);
|
|
9
14
|
return () => {};
|
|
10
15
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-open-with",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"files": [
|
|
5
5
|
"assets/",
|
|
6
6
|
"paseo-plugin.json",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"client/",
|
|
14
14
|
"server/",
|
|
15
15
|
"shared/",
|
|
16
|
+
"docs/images/*.png",
|
|
16
17
|
"LICENSE",
|
|
17
18
|
"!**/*.test.ts"
|
|
18
19
|
],
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
"typescript": "^5.9.3",
|
|
28
29
|
"zod": "^4.4.3"
|
|
29
30
|
},
|
|
30
|
-
"description": "Paseo plugin:
|
|
31
|
+
"description": "Paseo plugin: open the workspace folder, or a file link in an agent reply, in Fork, GitHub Desktop, VS Code, Cursor, a terminal, or any app you add. Right-click a file link or image to show it in its folder or copy its path.",
|
|
31
32
|
"author": "stv1024",
|
|
32
33
|
"homepage": "https://github.com/stv1024/paseo-open-with#readme",
|
|
33
34
|
"repository": {
|
|
@@ -43,7 +44,10 @@
|
|
|
43
44
|
"paseo-plugin",
|
|
44
45
|
"open-with",
|
|
45
46
|
"fork",
|
|
46
|
-
"editor"
|
|
47
|
+
"editor",
|
|
48
|
+
"file",
|
|
49
|
+
"links",
|
|
50
|
+
"context-menu"
|
|
47
51
|
],
|
|
48
52
|
"publishConfig": {
|
|
49
53
|
"access": "public",
|