paseo-open-with 0.1.1 → 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/paseo-plugin.json CHANGED
@@ -1,15 +1,18 @@
1
1
  {
2
2
  "id": "open-with",
3
3
  "name": "Open With",
4
- "description": "Open the workspace folder in Fork, GitHub Desktop, VS Code, Cursor, a terminal, or any app you add, in two clicks from the header.",
4
+ "description": "Open the workspace, or a file link in an agent reply, in Fork, 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.",
5
5
  "icon": "assets/icon.png",
6
6
  "media": [
7
+ "docs/images/promo.png",
7
8
  "docs/images/menu.png",
8
- "docs/images/settings.png"
9
+ "docs/images/file-menu.png",
10
+ "docs/images/settings.png",
11
+ "docs/images/file-menu-zh-CN.png"
9
12
  ],
10
13
  "translations": {
11
14
  "zh-CN": {
12
- "description": "在顶栏两次点击,用 Fork、GitHub Desktop、VS Code、Cursor、终端或你添加的任何程序打开 workspace 目录。"
15
+ "description": "用 Fork、VS Code、Cursor、终端或你添加的任何程序,打开 workspace 或 Agent 回复里的文件链接。右击文件链接或图片,可以在文件夹中显示、复制路径。"
13
16
  }
14
17
  },
15
18
  "requirements": {
package/server/apps.ts CHANGED
@@ -30,6 +30,8 @@ interface Preset {
30
30
  commands(runtime: Runtime): string[];
31
31
  /** Where to read the program icon when the command is a script. */
32
32
  iconFrom?(runtime: Runtime): string[];
33
+ /** What the app opens for a file: its folder, or the git repository that holds it. */
34
+ target?: "folder" | "repo";
33
35
  }
34
36
 
35
37
  const home = (runtime: Runtime) => runtime.env.HOME ?? runtime.env.USERPROFILE ?? "";
@@ -57,6 +59,7 @@ export const PRESETS: Record<PresetId, Preset> = {
57
59
  : r.platform === "darwin"
58
60
  ? [...macApp(r, "Fork", "Contents/Resources/fork_cli"), "fork"]
59
61
  : [],
62
+ target: "repo",
60
63
  },
61
64
  "github-desktop": {
62
65
  name: "GitHub Desktop",
@@ -68,6 +71,7 @@ export const PRESETS: Record<PresetId, Preset> = {
68
71
  ? [...macApp(r, "GitHub Desktop", "Contents/Resources/app/static/github.sh"), "github"]
69
72
  : ["github-desktop"],
70
73
  iconFrom: (r) => (r.platform === "win32" ? winCandidates(r, ["GitHubDesktop\\GitHubDesktop.exe"], []) : []),
74
+ target: "repo",
71
75
  },
72
76
  vscode: {
73
77
  name: "VS Code",
@@ -104,6 +108,7 @@ export const PRESETS: Record<PresetId, Preset> = {
104
108
  : r.platform === "darwin"
105
109
  ? ["/usr/bin/open"]
106
110
  : ["x-terminal-emulator", "gnome-terminal", "konsole"],
111
+ target: "folder",
107
112
  },
108
113
  "file-manager": {
109
114
  name: "File Manager",
@@ -185,6 +190,19 @@ export function describeEntry(entry: Entry, runtime: Runtime): App {
185
190
  };
186
191
  }
187
192
 
193
+ /** The path an app gets for a file or folder. Git clients get the repository, terminals the folder. */
194
+ export function targetPath(app: App, path: string, isDirectory: boolean, runtime: Runtime): string {
195
+ const kind = app.preset ? PRESETS[app.preset as PresetId].target : undefined;
196
+ if (!kind) return path;
197
+ const api = runtime.platform === "win32" ? win32 : posix;
198
+ const folder = isDirectory ? path : api.dirname(path);
199
+ if (kind === "folder") return folder;
200
+ for (let dir = folder; ; dir = api.dirname(dir)) {
201
+ if (runtime.exists(api.join(dir, ".git"))) return dir;
202
+ if (api.dirname(dir) === dir) return folder;
203
+ }
204
+ }
205
+
188
206
  export interface Command {
189
207
  executable: string;
190
208
  args: string[];
@@ -0,0 +1,191 @@
1
+ import { spawn } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { posix, win32 } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
7
+ import type { PluginHandlerContext } from "@getpaseo/plugin/server";
8
+ import { fileLinkRpc, parseLocalHref } from "../shared/file-links";
9
+ import type { OpenResult } from "./open-with";
10
+
11
+ type FileLinkInput = RpcInput<typeof fileLinkRpc>;
12
+ type FileLinkOutput = RpcOutput<typeof fileLinkRpc>;
13
+ type PaseoApi = PluginHandlerContext["paseo"];
14
+
15
+ export interface Command {
16
+ executable: string;
17
+ args: string[];
18
+ verbatim?: boolean;
19
+ /** Wait for the exit code, and try the next command when it is not 0. */
20
+ check?: boolean;
21
+ }
22
+
23
+ /** Opens a file or folder in an app from the Open With list. */
24
+ export type OpenApp = (appId: string, path: string, isDirectory: boolean) => Promise<OpenResult>;
25
+
26
+ class FileLinkError extends Error {
27
+ constructor(
28
+ readonly reason: "invalid" | "no-base",
29
+ message: string,
30
+ ) {
31
+ super(message);
32
+ }
33
+ }
34
+
35
+ function pathApi(platform: NodeJS.Platform) {
36
+ return platform === "win32" ? win32 : posix;
37
+ }
38
+
39
+ /** Turns an href into an absolute path. `base` is only read for relative paths. */
40
+ export async function hrefToAbsolutePath(
41
+ href: string,
42
+ base: () => Promise<string | null>,
43
+ platform: NodeJS.Platform = process.platform,
44
+ ): Promise<string> {
45
+ const local = parseLocalHref(href);
46
+ if (!local) {
47
+ throw new FileLinkError("invalid", "The link has no local path.");
48
+ }
49
+ const path = pathApi(platform);
50
+ if (local.kind === "file-url") {
51
+ try {
52
+ return path.resolve(fileURLToPath(local.url, { windows: platform === "win32" }));
53
+ } catch {
54
+ throw new FileLinkError("invalid", "The file URL is not valid.");
55
+ }
56
+ }
57
+
58
+ const raw = local.path;
59
+ if (raw === "~" || /^~[\\/]/.test(raw)) {
60
+ return path.resolve(homedir(), raw.slice(2));
61
+ }
62
+ if (path.isAbsolute(raw)) {
63
+ return path.resolve(raw);
64
+ }
65
+ const root = await base();
66
+ if (!root) {
67
+ throw new FileLinkError("no-base", "The workspace folder is unknown.");
68
+ }
69
+ return path.resolve(root, raw);
70
+ }
71
+
72
+ /** The commands to try in order. `reveal` selects the file or folder in its parent folder. */
73
+ export function buildCommands(
74
+ action: "reveal" | "open",
75
+ filePath: string,
76
+ platform: NodeJS.Platform = process.platform,
77
+ ): Command[] {
78
+ if (platform === "win32") {
79
+ // explorer.exe needs the quotes inside the `/select,` argument, so pass it verbatim.
80
+ const quoted = `"${filePath}"`;
81
+ return [{ executable: "explorer.exe", args: [action === "reveal" ? `/select,${quoted}` : quoted], verbatim: true }];
82
+ }
83
+ if (platform === "darwin") {
84
+ return [{ executable: "/usr/bin/open", args: action === "reveal" ? ["-R", filePath] : [filePath] }];
85
+ }
86
+ if (action === "open") {
87
+ return [{ executable: "xdg-open", args: [filePath] }];
88
+ }
89
+ // Most Linux file managers select items through this D-Bus call. Others get the parent folder.
90
+ const uri = "file://" + filePath.split("/").map(encodeURIComponent).join("/");
91
+ return [
92
+ {
93
+ executable: "dbus-send",
94
+ args: [
95
+ "--session",
96
+ "--print-reply",
97
+ "--dest=org.freedesktop.FileManager1",
98
+ "/org/freedesktop/FileManager1",
99
+ "org.freedesktop.FileManager1.ShowItems",
100
+ `array:string:${uri}`,
101
+ "string:",
102
+ ],
103
+ check: true,
104
+ },
105
+ { executable: "xdg-open", args: [posix.dirname(filePath)] },
106
+ ];
107
+ }
108
+
109
+ function run(command: Command): Promise<void> {
110
+ return new Promise((resolve, reject) => {
111
+ const child = spawn(command.executable, command.args, {
112
+ detached: !command.check,
113
+ stdio: "ignore",
114
+ shell: false,
115
+ windowsVerbatimArguments: command.verbatim === true,
116
+ });
117
+ child.once("error", reject);
118
+ if (command.check) {
119
+ child.once("exit", (code) => (code === 0 ? resolve() : reject(new Error(`exit ${code}`))));
120
+ return;
121
+ }
122
+ // explorer.exe exits with code 1 even on success, so only a spawn failure counts.
123
+ child.once("spawn", () => {
124
+ child.unref();
125
+ resolve();
126
+ });
127
+ });
128
+ }
129
+
130
+ async function runFirst(commands: Command[]): Promise<void> {
131
+ let failure: unknown;
132
+ for (const command of commands) {
133
+ try {
134
+ return await run(command);
135
+ } catch (error) {
136
+ failure = error;
137
+ }
138
+ }
139
+ throw failure;
140
+ }
141
+
142
+ async function workspaceRoot(input: FileLinkInput, paseo: PaseoApi): Promise<string | null> {
143
+ if (input.agentId) {
144
+ const agent = paseo.agents.ref(input.agentId);
145
+ await agent.refresh();
146
+ if (agent.cwd) {
147
+ return agent.cwd;
148
+ }
149
+ }
150
+ if (input.workspaceId) {
151
+ const workspace = await paseo.workspaces.ref(input.workspaceId).refresh();
152
+ return workspace?.workspaceDirectory ?? null;
153
+ }
154
+ return null;
155
+ }
156
+
157
+ export async function actOnFileLink(
158
+ input: FileLinkInput,
159
+ paseo: PaseoApi,
160
+ openApp: OpenApp,
161
+ ): Promise<FileLinkOutput> {
162
+ let filePath: string;
163
+ try {
164
+ filePath = await hrefToAbsolutePath(input.href, () => workspaceRoot(input, paseo));
165
+ } catch (error) {
166
+ return { ok: false, reason: error instanceof FileLinkError ? error.reason : "failed" };
167
+ }
168
+
169
+ let isDirectory: boolean;
170
+ try {
171
+ isDirectory = (await stat(filePath)).isDirectory();
172
+ } catch {
173
+ return { ok: false, reason: "not-found", path: filePath };
174
+ }
175
+
176
+ if (input.action === "app") {
177
+ if (!input.appId) {
178
+ return { ok: false, reason: "unknown-app", path: filePath };
179
+ }
180
+ const result = await openApp(input.appId, filePath, isDirectory);
181
+ return result.ok ? { ok: true, path: filePath, isDirectory } : { ...result, path: filePath };
182
+ }
183
+ if (input.action !== "resolve") {
184
+ try {
185
+ await runFirst(buildCommands(input.action, filePath));
186
+ } catch {
187
+ return { ok: false, reason: "failed", path: filePath };
188
+ }
189
+ }
190
+ return { ok: true, path: filePath, isDirectory };
191
+ }
@@ -1,13 +1,14 @@
1
1
  import { stat } from "node:fs/promises";
2
+ import { dirname } from "node:path";
2
3
  import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
3
4
  import type { PluginHandlerContext, PluginSettings } from "@getpaseo/plugin/server";
4
5
  import { appsRpc, launchRpc, mergeEntries, settingsSchema, type App } from "../shared/open-with";
5
- import { buildCommand, describeEntry, launch, nodeRuntime, PRESETS, resolveCommand } from "./apps";
6
+ import { buildCommand, describeEntry, launch, nodeRuntime, PRESETS, resolveCommand, targetPath } from "./apps";
6
7
  import { readIcons } from "./icons";
7
8
  import type { PresetId } from "../shared/open-with";
8
9
 
9
10
  type Paseo = PluginHandlerContext["paseo"];
10
- type Settings = PluginSettings<typeof settingsSchema>;
11
+ export type Settings = PluginSettings<typeof settingsSchema>;
11
12
 
12
13
  async function storedEntries(settings: Settings) {
13
14
  const state = await settings.read();
@@ -41,24 +42,40 @@ async function workspaceFolder(paseo: Paseo, workspaceId: string): Promise<strin
41
42
  return workspace?.workspaceDirectory ?? ref.directory ?? null;
42
43
  }
43
44
 
44
- export async function launchApp(
45
- input: RpcInput<typeof launchRpc>,
45
+ export type OpenResult =
46
+ | { ok: true }
47
+ | { ok: false; reason: "unknown-app" | "not-installed" | "failed"; message?: string };
48
+
49
+ /** Opens a file or folder in one app from the list. */
50
+ export async function openInApp(
51
+ appId: string,
52
+ path: string,
53
+ isDirectory: boolean,
46
54
  settings: Settings,
47
- paseo: Paseo,
48
- ): Promise<RpcOutput<typeof launchRpc>> {
49
- const entry = (await storedEntries(settings)).find((e) => e.id === input.appId);
55
+ ): Promise<OpenResult> {
56
+ const entry = (await storedEntries(settings)).find((e) => e.id === appId);
50
57
  if (!entry) return { ok: false, reason: "unknown-app" };
51
58
  const app = describeEntry(entry, nodeRuntime);
52
59
  if (!app.command) return { ok: false, reason: "not-installed" };
53
60
 
54
- const folder = await workspaceFolder(paseo, input.workspaceId).catch(() => null);
55
- if (!folder || !(await stat(folder).then((s) => s.isDirectory(), () => false))) {
56
- return { ok: false, reason: "no-folder" };
57
- }
61
+ const target = targetPath(app, path, isDirectory, nodeRuntime);
62
+ const cwd = target === path && !isDirectory ? dirname(path) : target;
58
63
  try {
59
- await launch(buildCommand(app, folder, nodeRuntime), folder);
64
+ await launch(buildCommand(app, target, nodeRuntime), cwd);
60
65
  return { ok: true };
61
66
  } catch (error) {
62
67
  return { ok: false, reason: "failed", message: error instanceof Error ? error.message : String(error) };
63
68
  }
64
69
  }
70
+
71
+ export async function launchApp(
72
+ input: RpcInput<typeof launchRpc>,
73
+ settings: Settings,
74
+ paseo: Paseo,
75
+ ): Promise<RpcOutput<typeof launchRpc>> {
76
+ const folder = await workspaceFolder(paseo, input.workspaceId).catch(() => null);
77
+ if (!folder || !(await stat(folder).then((s) => s.isDirectory(), () => false))) {
78
+ return { ok: false, reason: "no-folder" };
79
+ }
80
+ return openInApp(input.appId, folder, true, settings);
81
+ }
@@ -0,0 +1,109 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ /** `app` opens the file in the Open With app given by `appId`. */
5
+ export const fileLinkActionSchema = z.enum(["resolve", "reveal", "open", "app"]);
6
+ export type FileLinkAction = z.infer<typeof fileLinkActionSchema>;
7
+
8
+ export const fileLinkFailureSchema = z.enum([
9
+ "invalid",
10
+ "no-base",
11
+ "not-found",
12
+ "failed",
13
+ "unknown-app",
14
+ "not-installed",
15
+ ]);
16
+ export type FileLinkFailure = z.infer<typeof fileLinkFailureSchema>;
17
+
18
+ export const fileLinkRpc = defineRpc({
19
+ name: "open-with.file",
20
+ input: z.object({
21
+ href: z.string().min(1).max(16_384),
22
+ action: fileLinkActionSchema,
23
+ appId: z.string().min(1).max(64).optional(),
24
+ agentId: z.string().min(1).max(512).optional(),
25
+ workspaceId: z.string().min(1).max(512).optional(),
26
+ }),
27
+ output: z.discriminatedUnion("ok", [
28
+ z.object({ ok: z.literal(true), path: z.string(), isDirectory: z.boolean() }),
29
+ z.object({
30
+ ok: z.literal(false),
31
+ reason: fileLinkFailureSchema,
32
+ path: z.string().optional(),
33
+ message: z.string().optional(),
34
+ }),
35
+ ]),
36
+ });
37
+
38
+ export type LocalHref =
39
+ | { kind: "file-url"; url: string }
40
+ | { kind: "path"; path: string };
41
+
42
+ const LINE_FRAGMENT = /#L[0-9]+(?:C[0-9]+)?(?:-L?[0-9]+(?:C[0-9]+)?)?$/i;
43
+ const COLON_LINE_SUFFIX = /:[0-9]+(?::[0-9]+)?(?:-[0-9]+(?::[0-9]+)?)?$/;
44
+ const PAREN_LINE_SUFFIX = /\([0-9]+(?:,[0-9]+)?(?:-[0-9]+(?:,[0-9]+)?)?\)$/;
45
+ const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/;
46
+ const URL_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
47
+
48
+ function safeDecode(value: string): string {
49
+ try {
50
+ return decodeURIComponent(value);
51
+ } catch {
52
+ return value;
53
+ }
54
+ }
55
+
56
+ /** Removes `:12`, `:12:3`, `#L12-L20` and `(12,3)` so the link points at the file itself. */
57
+ export function stripSourcePosition(value: string): string {
58
+ return value
59
+ .replace(LINE_FRAGMENT, "")
60
+ .replace(COLON_LINE_SUFFIX, "")
61
+ .replace(PAREN_LINE_SUFFIX, "");
62
+ }
63
+
64
+ /** Returns the local file target of an assistant link, or null for web and app links. */
65
+ export function parseLocalHref(href: string): LocalHref | null {
66
+ const trimmed = href.trim();
67
+ if (!trimmed || trimmed.startsWith("#")) {
68
+ return null;
69
+ }
70
+ if (/^file:/i.test(trimmed)) {
71
+ return { kind: "file-url", url: trimmed.replace(LINE_FRAGMENT, "") };
72
+ }
73
+
74
+ const path = stripSourcePosition(safeDecode(trimmed)).trim();
75
+ if (!path) {
76
+ return null;
77
+ }
78
+ if (WINDOWS_DRIVE.test(path) || path.startsWith("\\\\")) {
79
+ return { kind: "path", path };
80
+ }
81
+ if (URL_SCHEME.test(path)) {
82
+ return null;
83
+ }
84
+ return { kind: "path", path };
85
+ }
86
+
87
+ export interface RouteContext {
88
+ serverId?: string;
89
+ agentId?: string;
90
+ workspaceId?: string;
91
+ }
92
+
93
+ export function getRouteContext(pathname: string): RouteContext {
94
+ const pathOnly = pathname.split(/[?#]/, 1)[0] ?? "";
95
+ const host = pathOnly.match(/^\/h\/([^/]+)(?:\/|$)/);
96
+ if (!host?.[1]) {
97
+ return {};
98
+ }
99
+ const serverId = safeDecode(host[1]);
100
+ const agent = pathOnly.match(/^\/h\/[^/]+\/agent\/([^/]+)(?:\/|$)/);
101
+ if (agent?.[1]) {
102
+ return { serverId, agentId: safeDecode(agent[1]) };
103
+ }
104
+ const workspace = pathOnly.match(/^\/h\/[^/]+\/workspace\/([^/]+)(?:\/|$)/);
105
+ if (workspace?.[1]) {
106
+ return { serverId, workspaceId: safeDecode(workspace[1]) };
107
+ }
108
+ return { serverId };
109
+ }