paseo-open-with 0.1.1 → 0.2.1
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 -17
- 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 -5
- package/paseo-plugin.json +6 -3
- package/server/apps.ts +33 -6
- package/server/file-links.ts +191 -0
- package/server/open-with.ts +29 -12
- package/shared/file-links.ts +109 -0
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
|
|
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/
|
|
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": "
|
|
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",
|
|
@@ -113,12 +118,20 @@ export const PRESETS: Record<PresetId, Preset> = {
|
|
|
113
118
|
},
|
|
114
119
|
};
|
|
115
120
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Arguments that differ by platform for one preset, where `{path}` alone is not enough.
|
|
123
|
+
* `command` is the program that runs, because Linux terminals take different options.
|
|
124
|
+
*/
|
|
125
|
+
function presetArgs(preset: PresetId, command: string | null, runtime: Runtime): string {
|
|
118
126
|
if (preset === "terminal") {
|
|
119
127
|
if (runtime.platform === "win32") return `-d "${PATH_TOKEN}"`;
|
|
120
128
|
if (runtime.platform === "darwin") return `-a Terminal "${PATH_TOKEN}"`;
|
|
121
|
-
|
|
129
|
+
const program = command ? posix.basename(command) : "";
|
|
130
|
+
if (program === "konsole") return `--workdir "${PATH_TOKEN}"`;
|
|
131
|
+
if (program === "gnome-terminal") return `--working-directory="${PATH_TOKEN}"`;
|
|
132
|
+
// x-terminal-emulator can be any terminal. It starts in the folder because
|
|
133
|
+
// `launch` sets the working directory.
|
|
134
|
+
return "";
|
|
122
135
|
}
|
|
123
136
|
return `"${PATH_TOKEN}"`;
|
|
124
137
|
}
|
|
@@ -166,13 +179,14 @@ export function expandEnv(value: string, runtime: Runtime): string {
|
|
|
166
179
|
/** The menu entry after merging stored fields over the preset. */
|
|
167
180
|
export function describeEntry(entry: Entry, runtime: Runtime): App {
|
|
168
181
|
const preset = entry.preset ? PRESETS[entry.preset as PresetId] : undefined;
|
|
182
|
+
const defaultCommand = preset ? resolveCommand(preset.commands(runtime), runtime) : null;
|
|
183
|
+
const command = entry.command ? resolveCommand([entry.command], runtime) : defaultCommand;
|
|
169
184
|
const defaults = {
|
|
170
185
|
name: preset?.name ?? "",
|
|
171
186
|
icon: preset?.icon ?? "AppWindow",
|
|
172
|
-
command:
|
|
173
|
-
args: preset ? presetArgs(entry.preset as PresetId, runtime) : `"${PATH_TOKEN}"`,
|
|
187
|
+
command: defaultCommand,
|
|
188
|
+
args: preset ? presetArgs(entry.preset as PresetId, command, runtime) : `"${PATH_TOKEN}"`,
|
|
174
189
|
};
|
|
175
|
-
const command = entry.command ? resolveCommand([entry.command], runtime) : defaults.command;
|
|
176
190
|
return {
|
|
177
191
|
id: entry.id,
|
|
178
192
|
preset: entry.preset,
|
|
@@ -185,6 +199,19 @@ export function describeEntry(entry: Entry, runtime: Runtime): App {
|
|
|
185
199
|
};
|
|
186
200
|
}
|
|
187
201
|
|
|
202
|
+
/** The path an app gets for a file or folder. Git clients get the repository, terminals the folder. */
|
|
203
|
+
export function targetPath(app: App, path: string, isDirectory: boolean, runtime: Runtime): string {
|
|
204
|
+
const kind = app.preset ? PRESETS[app.preset as PresetId].target : undefined;
|
|
205
|
+
if (!kind) return path;
|
|
206
|
+
const api = runtime.platform === "win32" ? win32 : posix;
|
|
207
|
+
const folder = isDirectory ? path : api.dirname(path);
|
|
208
|
+
if (kind === "folder") return folder;
|
|
209
|
+
for (let dir = folder; ; dir = api.dirname(dir)) {
|
|
210
|
+
if (runtime.exists(api.join(dir, ".git"))) return dir;
|
|
211
|
+
if (api.dirname(dir) === dir) return folder;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
188
215
|
export interface Command {
|
|
189
216
|
executable: string;
|
|
190
217
|
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
|
+
}
|
package/server/open-with.ts
CHANGED
|
@@ -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
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
)
|
|
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
|
|
55
|
-
|
|
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,
|
|
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
|
+
}
|