paseo-open-with 0.0.0-bootstrap.0 → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stephen Zhou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/OVERVIEW.md ADDED
@@ -0,0 +1,19 @@
1
+ Open With adds a button to the workspace header. Click it, then click an app, and the workspace folder opens in that app.
2
+
3
+ The menu starts with Fork, GitHub Desktop, VS Code, Cursor, a terminal, and the file manager. It shows only the apps it finds on the computer that runs the daemon. Where it can, it shows each app's own icon.
4
+
5
+ ## Add your own apps
6
+
7
+ Choose **Edit apps…** at the bottom of the menu, or open the plugin settings. There you can:
8
+
9
+ - Add any program, with its path and its arguments. `{path}` in the arguments is the workspace folder. Put double quotes around a value with spaces, for example `-d "{path}"`.
10
+ - Change the program path of a built-in app, for example when it is installed in an unusual folder.
11
+ - Turn apps off, and move them up or down in the menu.
12
+
13
+ The settings belong to each daemon, because apps are installed in different places on each computer.
14
+
15
+ ## Limits
16
+
17
+ The daemon starts the app, so the app opens on the computer that runs the daemon. On a remote host, it opens on that host.
18
+
19
+ Built-in paths are known for Windows and macOS. On Linux, the built-in apps must be on `PATH`. Program icons are read on Windows only. On other systems the menu shows a generic icon.
@@ -0,0 +1,19 @@
1
+ Open With 在 workspace 顶栏加一个按钮。点按钮,再点一个程序,就用这个程序打开 workspace 目录。
2
+
3
+ 菜单预置了 Fork、GitHub Desktop、VS Code、Cursor、终端和文件管理器。菜单只显示在运行 daemon 的电脑上找到的程序。能读到程序图标时,显示程序自己的图标。
4
+
5
+ ## 添加自己的程序
6
+
7
+ 点菜单底部的 **编辑程序…**,或打开插件设置。在设置里可以:
8
+
9
+ - 添加任何程序,填写程序路径和参数。参数里的 `{path}` 代表 workspace 目录。值里有空格时,用双引号括起来,例如 `-d "{path}"`。
10
+ - 修改预置程序的路径,例如程序装在不常见的目录时。
11
+ - 关闭不用的程序,在菜单里上移或下移程序。
12
+
13
+ 设置按 daemon 分别保存,因为每台电脑上程序的安装位置不同。
14
+
15
+ ## 限制
16
+
17
+ 程序由 daemon 启动,所以程序在运行 daemon 的电脑上打开。连接远程主机时,程序在那台主机上打开。
18
+
19
+ 预置程序的路径支持 Windows 和 macOS。在 Linux 上,预置程序必须在 `PATH` 里。只在 Windows 上读取程序图标,其他系统显示通用图标。
package/README.md CHANGED
@@ -1,3 +1,91 @@
1
- # paseo-open-with
1
+ # Open With for Paseo
2
2
 
3
- Placeholder. See https://github.com/stv1024/paseo-open-with.
3
+ A [Paseo](https://paseo.sh) plugin that opens the workspace folder in another app,
4
+ in two clicks from the workspace header.
5
+
6
+ Paseo's own "Open in" button has a fixed list of editors, and you cannot add an
7
+ app such as Fork. Open With has a menu that you can change: add any program, turn
8
+ apps off, and put them in your order.
9
+
10
+ ## Install
11
+
12
+ Requires **Paseo 0.13.2 or later**.
13
+
14
+ ```bash
15
+ paseo plugin install https://github.com/stv1024/paseo-open-with
16
+ ```
17
+
18
+ If the button does not show, reload the Paseo window (**Ctrl+R**, or **Cmd+R** on macOS).
19
+
20
+ ## Use
21
+
22
+ Click the button in the workspace header, then click an app.
23
+
24
+ Built-in apps:
25
+
26
+ | App | Windows | macOS | Linux |
27
+ | --- | --- | --- | --- |
28
+ | Fork | `%LOCALAPPDATA%\Fork` | `/Applications/Fork.app` | - |
29
+ | GitHub Desktop | `%LOCALAPPDATA%\GitHubDesktop` | `/Applications/GitHub Desktop.app` | `github-desktop` |
30
+ | VS Code | user or system install, or `code` | `/Applications/Visual Studio Code.app`, or `code` | `code` |
31
+ | Cursor | user or system install, or `cursor` | `/Applications/Cursor.app`, or `cursor` | `cursor` |
32
+ | Terminal | Windows Terminal (`wt`) | Terminal.app | `x-terminal-emulator`, `gnome-terminal`, `konsole` |
33
+ | File Manager | File Explorer | Finder | `xdg-open` |
34
+
35
+ The menu shows only the apps it finds. Apps on `PATH` are found too.
36
+
37
+ ## Add an app
38
+
39
+ Choose **Edit apps…** at the bottom of the menu. Then choose **Add app** and fill in:
40
+
41
+ | Field | Example |
42
+ | --- | --- |
43
+ | Program | `C:\Program Files\Sublime Merge\sublime_merge.exe` |
44
+ | Arguments | `"{path}"` |
45
+ | Name | `Sublime Merge` |
46
+ | Icon | a [Lucide](https://lucide.dev/icons) icon name, used when Paseo cannot read the program icon |
47
+
48
+ `{path}` is the workspace folder. Arguments split on spaces. Double quotes keep a
49
+ value with spaces together, and backslashes stay as they are. `%VAR%` (Windows) and
50
+ a leading `~` in the program path are expanded. A program name without a folder is
51
+ looked up on `PATH`.
52
+
53
+ To change a built-in app, click it and fill in only the fields to change. **Reset**
54
+ returns it to the defaults.
55
+
56
+ Settings are saved per daemon, so each computer keeps its own list.
57
+
58
+ ## How it works
59
+
60
+ The daemon finds the apps, reads their icons (Windows only), and starts the app
61
+ with the workspace folder. So the app opens on the computer that runs the daemon.
62
+ A launch error shows as a toast.
63
+
64
+ ## Develop
65
+
66
+ ```bash
67
+ npm install
68
+ npm run typecheck
69
+ npm test
70
+ paseo plugin install .
71
+ ```
72
+
73
+ ### Release
74
+
75
+ Releases go to npm as `paseo-open-with` through
76
+ [`.github/workflows/release.yml`](.github/workflows/release.yml), with npm Trusted
77
+ Publishing (no npm token). Bump `version` in `package.json`, add a `CHANGELOG.md`
78
+ section, then commit, tag and push:
79
+
80
+ ```bash
81
+ npm run typecheck && npm test && npm run pack:npm # check the packed file list
82
+ git push origin main
83
+ git tag -a v0.1.0 -m "Open With 0.1.0" && git push origin v0.1.0
84
+ ```
85
+
86
+ The tag push publishes the tarball. Then create the GitHub release from the tag.
87
+ The official registry opens a bump PR for each new npm version.
88
+
89
+ ## License
90
+
91
+ MIT
Binary file
@@ -0,0 +1,134 @@
1
+ import type { ComponentType } from "react";
2
+ import { Image } from "react-native";
3
+ import type {
4
+ PluginButtonBehavior,
5
+ PluginButtonIconProps,
6
+ PluginButtonMenuEntry,
7
+ PluginButtonRegistration,
8
+ PluginClientContext,
9
+ } from "@getpaseo/plugin/client";
10
+ import { appsRpc, launchRpc, type App } from "../shared/open-with";
11
+ import { messages } from "./messages";
12
+
13
+ export const SETTINGS_ID = "open-with";
14
+ const REFRESH_MS = 10 * 60 * 1000;
15
+
16
+ const imageIcons = new Map<string, ComponentType<PluginButtonIconProps>>();
17
+
18
+ /** One component per image, so menus keep a stable icon identity across refreshes. */
19
+ function imageIcon(uri: string): ComponentType<PluginButtonIconProps> {
20
+ let icon = imageIcons.get(uri);
21
+ if (!icon) {
22
+ icon = ({ size }: PluginButtonIconProps) => <Image source={{ uri }} style={{ width: size, height: size }} />;
23
+ imageIcons.set(uri, icon);
24
+ }
25
+ return icon;
26
+ }
27
+
28
+ export interface OpenWithMenu {
29
+ /** Reads the app list again and rebuilds every menu. */
30
+ refresh(): Promise<void>;
31
+ dispose(): void;
32
+ }
33
+
34
+ export function installMenu(client: PluginClientContext): OpenWithMenu {
35
+ const buttons = new Map<string, PluginButtonRegistration>();
36
+ let apps: App[] = [];
37
+ let disposed = false;
38
+
39
+ const open = async (workspaceId: string, app: App) => {
40
+ const result = await client.rpc(launchRpc, { workspaceId, appId: app.id });
41
+ if (result.ok) return;
42
+ const text =
43
+ result.reason === "not-installed"
44
+ ? client.i18n.t(messages, "notInstalled", { name: app.name })
45
+ : result.reason === "no-folder"
46
+ ? client.i18n.t(messages, "noFolder")
47
+ : client.i18n.t(messages, "failed", { name: app.name });
48
+ // Paseo shows a rejected press as an error.
49
+ throw new Error(result.message ? `${text}: ${result.message}` : text);
50
+ };
51
+
52
+ const behavior = (workspaceId: string): PluginButtonBehavior => {
53
+ const items: PluginButtonMenuEntry[] = apps
54
+ .filter((app) => app.enabled && app.command)
55
+ .map((app) => ({
56
+ kind: "item",
57
+ id: app.id,
58
+ title: app.name,
59
+ icon: app.image ? imageIcon(app.image) : app.icon,
60
+ behavior: { kind: "action", onPress: () => open(workspaceId, app) },
61
+ }));
62
+ if (items.length === 0) {
63
+ items.push({ kind: "item", id: "none", title: client.i18n.t(messages, "noApps"), disabled: true, behavior: { kind: "action", onPress() {} } });
64
+ }
65
+ items.push(
66
+ { kind: "separator", id: "settings-separator" },
67
+ {
68
+ kind: "item",
69
+ id: "settings",
70
+ title: client.i18n.t(messages, "edit"),
71
+ icon: "Settings2",
72
+ behavior: { kind: "action", onPress: () => client.openSettings(SETTINGS_ID) },
73
+ },
74
+ );
75
+ return { kind: "menu", items };
76
+ };
77
+
78
+ const addButton = (workspaceId: string) => {
79
+ if (disposed || buttons.has(workspaceId)) return;
80
+ buttons.set(
81
+ workspaceId,
82
+ client.addHeaderButton({
83
+ id: "open-with",
84
+ workspaceId,
85
+ button: { title: client.i18n.t(messages, "button"), icon: "SquareArrowOutUpRight", behavior: behavior(workspaceId) },
86
+ }),
87
+ );
88
+ };
89
+
90
+ const removeButton = (workspaceId: string) => {
91
+ buttons.get(workspaceId)?.remove();
92
+ buttons.delete(workspaceId);
93
+ };
94
+
95
+ const refresh = async () => {
96
+ try {
97
+ apps = (await client.rpc(appsRpc, {})).apps;
98
+ } catch (error) {
99
+ console.error("[open-with] cannot read apps", error);
100
+ return;
101
+ }
102
+ if (disposed) return;
103
+ for (const [workspaceId, button] of buttons) button.update({ behavior: behavior(workspaceId) });
104
+ };
105
+
106
+ const unsubscribe = client.paseo.workspaces.subscribe((update) => {
107
+ if (update.kind === "upsert") addButton(update.workspace.id);
108
+ else removeButton(update.id);
109
+ });
110
+
111
+ void (async () => {
112
+ await refresh();
113
+ let cursor: string | undefined;
114
+ do {
115
+ const page = await client.paseo.workspaces.list({ page: { limit: 200, cursor } });
116
+ page.entries.forEach((workspace) => addButton(workspace.id));
117
+ cursor = page.pageInfo.hasMore ? (page.pageInfo.nextCursor ?? undefined) : undefined;
118
+ } while (cursor && !disposed);
119
+ })().catch((error: unknown) => console.error("[open-with] cannot list workspaces", error));
120
+
121
+ // Picks up apps installed or removed while Paseo stays open.
122
+ const timer = setInterval(() => void refresh(), REFRESH_MS);
123
+
124
+ return {
125
+ refresh,
126
+ dispose() {
127
+ disposed = true;
128
+ clearInterval(timer);
129
+ unsubscribe();
130
+ buttons.forEach((button) => button.remove());
131
+ buttons.clear();
132
+ },
133
+ };
134
+ }
@@ -0,0 +1,64 @@
1
+ import { defineTranslations } from "@getpaseo/plugin/client";
2
+
3
+ export const messages = defineTranslations({
4
+ resources: {
5
+ en: {
6
+ button: "Open with",
7
+ settings: "Open With",
8
+ edit: "Edit apps…",
9
+ noApps: "No apps found",
10
+ notInstalled: "{{name}} is not installed",
11
+ noFolder: "Workspace folder not found",
12
+ failed: "Could not open {{name}}",
13
+ apps: "Apps",
14
+ notFound: "Not found",
15
+ name: "Name",
16
+ command: "Program",
17
+ args: "Arguments",
18
+ argsHint: "{path} is the workspace folder. Use double quotes around values with spaces.",
19
+ icon: "Icon",
20
+ iconHint: "A Lucide icon name. The program icon is used when Paseo can read it.",
21
+ add: "Add app",
22
+ remove: "Remove",
23
+ reset: "Reset",
24
+ moveUp: "Move up",
25
+ moveDown: "Move down",
26
+ save: "Save",
27
+ saved: "Saved",
28
+ loading: "Loading…",
29
+ reload: "Reload",
30
+ restoreDefaults: "Restore defaults",
31
+ newApp: "New app",
32
+ commandRequired: "Enter a program for each app you added.",
33
+ },
34
+ "zh-CN": {
35
+ button: "用其他程序打开",
36
+ settings: "Open With",
37
+ edit: "编辑程序…",
38
+ noApps: "没有找到程序",
39
+ notInstalled: "没有安装 {{name}}",
40
+ noFolder: "找不到 workspace 目录",
41
+ failed: "无法打开 {{name}}",
42
+ apps: "程序",
43
+ notFound: "未找到",
44
+ name: "名称",
45
+ command: "程序",
46
+ args: "参数",
47
+ argsHint: "{path} 代表 workspace 目录。值里有空格时,用双引号括起来。",
48
+ icon: "图标",
49
+ iconHint: "Lucide 图标名。能读到程序图标时,用程序图标。",
50
+ add: "添加程序",
51
+ remove: "删除",
52
+ reset: "恢复默认",
53
+ moveUp: "上移",
54
+ moveDown: "下移",
55
+ save: "保存",
56
+ saved: "已保存",
57
+ loading: "正在加载…",
58
+ reload: "重新加载",
59
+ restoreDefaults: "恢复默认设置",
60
+ newApp: "新程序",
61
+ commandRequired: "请给每个添加的程序填写程序路径。",
62
+ },
63
+ },
64
+ });
@@ -0,0 +1,227 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { Image, Pressable, Switch, Text, View } from "react-native";
3
+ import { useRpc, useSettings, useTranslation, type PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import { Icon, SettingsAction, SettingsCard, SettingsInput, SettingsRow, SettingsSection } from "@getpaseo/plugin/client/ui";
5
+ import { appsRpc, mergeEntries, openWithSettings, type App, type Entry } from "../shared/open-with";
6
+ import { messages } from "./messages";
7
+
8
+ type Props = PluginSurfaceProps & { onSaved(): void };
9
+
10
+ export function SettingsScreen(props: Props) {
11
+ const state = useSettings(openWithSettings);
12
+ const { t } = useTranslation(messages);
13
+ const c = props.theme.colors;
14
+ if (state.status !== "ready") {
15
+ return (
16
+ <View style={{ padding: 24, gap: 12 }}>
17
+ <Text style={{ color: state.status === "loading" ? c.foregroundMuted : c.statusDanger }}>
18
+ {state.status === "loading" ? t("loading") : state.error}
19
+ </Text>
20
+ {state.status !== "loading" && (
21
+ <SettingsCard>
22
+ <SettingsAction label={t("reload")} actionLabel={t("reload")} onPress={() => void state.reload()} />
23
+ {state.status === "invalid" && (
24
+ <SettingsAction label={t("restoreDefaults")} actionLabel={t("reset")} onPress={() => void state.reset()} />
25
+ )}
26
+ </SettingsCard>
27
+ )}
28
+ </View>
29
+ );
30
+ }
31
+ return (
32
+ <Editor
33
+ key={state.revision}
34
+ {...props}
35
+ stored={state.values.entries}
36
+ saving={state.saving}
37
+ saveError={state.saveError}
38
+ save={(entries) => state.save({ entries }, state.revision)}
39
+ />
40
+ );
41
+ }
42
+
43
+ function Editor({
44
+ theme,
45
+ stored,
46
+ saving,
47
+ saveError,
48
+ save,
49
+ onSaved,
50
+ }: Props & {
51
+ stored: Entry[];
52
+ saving: boolean;
53
+ saveError: string | null;
54
+ save(entries: Entry[]): Promise<boolean>;
55
+ }) {
56
+ const { t } = useTranslation(messages);
57
+ const c = theme.colors;
58
+ const listApps = useRpc(appsRpc);
59
+ const [draft, setDraft] = useState(() => mergeEntries(stored));
60
+ const [apps, setApps] = useState<Map<string, App>>(new Map());
61
+ const [editing, setEditing] = useState<string | null>(null);
62
+ const [error, setError] = useState("");
63
+ const [saved, setSaved] = useState(false);
64
+
65
+ const loadApps = useCallback(() => {
66
+ void listApps({})
67
+ .then((result) => setApps(new Map(result.apps.map((app) => [app.id, app]))))
68
+ .catch(() => {});
69
+ }, [listApps]);
70
+ useEffect(loadApps, [loadApps]);
71
+
72
+ const change = (next: Entry[]) => {
73
+ setDraft(next);
74
+ setSaved(false);
75
+ setError("");
76
+ };
77
+ const patch = (id: string, fields: Partial<Entry>) =>
78
+ change(draft.map((entry) => (entry.id === id ? { ...entry, ...fields } : entry)));
79
+ const move = (index: number, by: number) => {
80
+ const next = [...draft];
81
+ const [entry] = next.splice(index, 1);
82
+ next.splice(index + by, 0, entry!);
83
+ change(next);
84
+ };
85
+ const add = () => {
86
+ const id = `custom-${Date.now().toString(36)}`;
87
+ change([...draft, { id, preset: "", name: "", icon: "", command: "", args: "", enabled: true }]);
88
+ setEditing(id);
89
+ };
90
+
91
+ const saveNow = async () => {
92
+ if (draft.some((entry) => !entry.preset && !entry.command.trim())) {
93
+ setError(t("commandRequired"));
94
+ return;
95
+ }
96
+ if (await save(draft)) {
97
+ setSaved(true);
98
+ onSaved();
99
+ }
100
+ };
101
+
102
+ const iconButton = (name: string, label: string, onPress: () => void, disabled = false) => (
103
+ <Pressable
104
+ accessibilityRole="button"
105
+ accessibilityLabel={label}
106
+ disabled={disabled}
107
+ onPress={onPress}
108
+ style={{ padding: 6, opacity: disabled ? 0.3 : 1 }}
109
+ >
110
+ <Icon name={name} size={16} color={c.foregroundMuted} />
111
+ </Pressable>
112
+ );
113
+
114
+ return (
115
+ <View style={{ gap: 16 }}>
116
+ <SettingsSection title={t("apps")}>
117
+ <SettingsCard>
118
+ {draft.map((entry, index) => {
119
+ const app = apps.get(entry.id);
120
+ const name = entry.name || app?.defaults.name || entry.command || t("newApp");
121
+ const open = editing === entry.id;
122
+ return (
123
+ <View key={entry.id}>
124
+ <Pressable
125
+ accessibilityRole="button"
126
+ accessibilityLabel={name}
127
+ onPress={() => setEditing(open ? null : entry.id)}
128
+ style={{ flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 16, paddingVertical: 10 }}
129
+ >
130
+ {app?.image ? (
131
+ <Image source={{ uri: app.image }} style={{ width: 20, height: 20 }} />
132
+ ) : (
133
+ <Icon name={entry.icon || app?.icon || "AppWindow"} size={20} color={c.foreground} />
134
+ )}
135
+ <View style={{ flex: 1, minWidth: 0 }}>
136
+ <Text style={{ color: c.foreground, fontWeight: "500" }} numberOfLines={1}>
137
+ {name}
138
+ </Text>
139
+ {app && (
140
+ <Text style={{ color: c.foregroundMuted, fontSize: 12 }} numberOfLines={1}>
141
+ {app.command ?? t("notFound")}
142
+ </Text>
143
+ )}
144
+ </View>
145
+ {iconButton("ChevronUp", t("moveUp"), () => move(index, -1), index === 0)}
146
+ {iconButton("ChevronDown", t("moveDown"), () => move(index, 1), index === draft.length - 1)}
147
+ <Switch value={entry.enabled} onValueChange={(enabled) => patch(entry.id, { enabled })} />
148
+ </Pressable>
149
+ {open && (
150
+ <EntryFields
151
+ key={`${entry.id}:${entry.preset ? JSON.stringify([entry.name, entry.command, entry.args, entry.icon]) : ""}`}
152
+ entry={entry}
153
+ app={app}
154
+ patch={(fields) => patch(entry.id, fields)}
155
+ remove={() => change(draft.filter((e) => e.id !== entry.id))}
156
+ />
157
+ )}
158
+ </View>
159
+ );
160
+ })}
161
+ <SettingsAction label="" actionLabel={t("add")} onPress={add} />
162
+ </SettingsCard>
163
+ </SettingsSection>
164
+ {(error || saveError) && <Text style={{ color: c.statusDanger }}>{error || saveError}</Text>}
165
+ <SettingsCard>
166
+ <SettingsAction
167
+ label={saved ? t("saved") : ""}
168
+ actionLabel={t("save")}
169
+ disabled={saving}
170
+ onPress={() => void saveNow()}
171
+ />
172
+ </SettingsCard>
173
+ </View>
174
+ );
175
+ }
176
+
177
+ function EntryFields({
178
+ entry,
179
+ app,
180
+ patch,
181
+ remove,
182
+ }: {
183
+ entry: Entry;
184
+ app: App | undefined;
185
+ patch(fields: Partial<Entry>): void;
186
+ remove(): void;
187
+ }) {
188
+ const { t } = useTranslation(messages);
189
+ const defaults = app?.defaults;
190
+ return (
191
+ <View>
192
+ <SettingsInput
193
+ label={t("name")}
194
+ initialValue={entry.name}
195
+ placeholder={defaults?.name}
196
+ onChangeText={(name) => patch({ name })}
197
+ />
198
+ <SettingsInput
199
+ label={t("command")}
200
+ initialValue={entry.command}
201
+ placeholder={defaults?.command ?? (entry.preset ? t("notFound") : "C:\\Program Files\\App\\App.exe")}
202
+ onChangeText={(command) => patch({ command })}
203
+ />
204
+ <SettingsInput
205
+ label={t("args")}
206
+ hint={t("argsHint")}
207
+ initialValue={entry.args}
208
+ placeholder={defaults?.args ?? '"{path}"'}
209
+ onChangeText={(args) => patch({ args })}
210
+ />
211
+ <SettingsInput
212
+ label={t("icon")}
213
+ hint={t("iconHint")}
214
+ initialValue={entry.icon}
215
+ placeholder={defaults?.icon ?? "AppWindow"}
216
+ onChangeText={(icon) => patch({ icon })}
217
+ />
218
+ <SettingsRow label="">
219
+ {entry.preset ? (
220
+ <SettingsAction label="" actionLabel={t("reset")} onPress={() => patch({ name: "", command: "", args: "", icon: "" })} />
221
+ ) : (
222
+ <SettingsAction label="" actionLabel={t("remove")} onPress={remove} />
223
+ )}
224
+ </SettingsRow>
225
+ </View>
226
+ );
227
+ }
@@ -0,0 +1,24 @@
1
+ import { ScrollView } from "react-native";
2
+ import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client";
3
+ import { installMenu, SETTINGS_ID } from "./client/menu";
4
+ import { messages } from "./client/messages";
5
+ import { SettingsScreen } from "./client/settings";
6
+
7
+ export default function contribute(client: PluginClientContext) {
8
+ const menu = installMenu(client);
9
+ const Settings = (props: PluginSurfaceProps) => (
10
+ <ScrollView contentContainerStyle={{ padding: 18, maxWidth: 720 }}>
11
+ <SettingsScreen {...props} onSaved={() => void menu.refresh()} />
12
+ </ScrollView>
13
+ );
14
+ const removeSettings = client.addSettingsScreen({
15
+ id: SETTINGS_ID,
16
+ title: client.i18n.t(messages, "settings"),
17
+ icon: "SquareArrowOutUpRight",
18
+ Component: Settings,
19
+ });
20
+ return () => {
21
+ removeSettings();
22
+ menu.dispose();
23
+ };
24
+ }
@@ -0,0 +1,10 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { appsRpc, launchRpc, openWithSettings } from "./shared/open-with";
3
+ import { launchApp, listApps } from "./server/open-with";
4
+
5
+ export default function contribute(server: PluginServerContext) {
6
+ const settings = server.registerSettings(openWithSettings);
7
+ server.handle(appsRpc, () => listApps(settings));
8
+ server.handle(launchRpc, (input, { paseo }) => launchApp(input, settings, paseo));
9
+ return () => {};
10
+ }
package/package.json CHANGED
@@ -1,10 +1,52 @@
1
1
  {
2
2
  "name": "paseo-open-with",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Placeholder that sets up npm Trusted Publishing. Install the latest version instead.",
5
- "license": "MIT",
3
+ "version": "0.1.0",
4
+ "files": [
5
+ "assets/",
6
+ "paseo-plugin.json",
7
+ "OVERVIEW.md",
8
+ "OVERVIEW.*.md",
9
+ "index.client.ts",
10
+ "index.client.tsx",
11
+ "index.server.ts",
12
+ "index.server.tsx",
13
+ "client/",
14
+ "server/",
15
+ "shared/",
16
+ "LICENSE",
17
+ "!**/*.test.ts"
18
+ ],
19
+ "devDependencies": {
20
+ "@getpaseo/plugin": "0.13.2",
21
+ "@tanstack/react-query": "^5.90.11",
22
+ "@types/node": "^22.20.5",
23
+ "@types/react": "~19.2.0",
24
+ "react": "19.1.0",
25
+ "react-native": "0.81.5",
26
+ "tsx": "^4.23.15",
27
+ "typescript": "^5.9.3",
28
+ "zod": "^4.4.3"
29
+ },
30
+ "description": "Paseo plugin: a header menu that opens the workspace folder in Fork, GitHub Desktop, VS Code, Cursor, a terminal, or any app you add.",
31
+ "author": "stv1024",
32
+ "homepage": "https://github.com/stv1024/paseo-open-with#readme",
6
33
  "repository": {
7
34
  "type": "git",
8
35
  "url": "git+https://github.com/stv1024/paseo-open-with.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/stv1024/paseo-open-with/issues"
39
+ },
40
+ "license": "MIT",
41
+ "keywords": [
42
+ "paseo",
43
+ "paseo-plugin",
44
+ "open-with",
45
+ "fork",
46
+ "editor"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "registry": "https://registry.npmjs.org/"
9
51
  }
10
52
  }
@@ -0,0 +1,13 @@
1
+ {
2
+ "id": "open-with",
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.",
5
+ "icon": "assets/icon.png",
6
+ "media": [],
7
+ "translations": {
8
+ "zh-CN": { "description": "在顶栏两次点击,用 Fork、GitHub Desktop、VS Code、Cursor、终端或你添加的任何程序打开 workspace 目录。" }
9
+ },
10
+ "requirements": {
11
+ "paseo": ">=0.13.2"
12
+ }
13
+ }
package/server/apps.ts ADDED
@@ -0,0 +1,234 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, lstatSync } from "node:fs";
3
+ import { posix, win32 } from "node:path";
4
+ import { PATH_TOKEN, splitArgs, type App, type Entry, type PresetId } from "../shared/open-with";
5
+
6
+ export interface Runtime {
7
+ platform: NodeJS.Platform;
8
+ env: NodeJS.ProcessEnv;
9
+ /** True for files and for Windows app execution aliases, which `existsSync` reports as missing. */
10
+ exists(path: string): boolean;
11
+ }
12
+
13
+ export const nodeRuntime: Runtime = {
14
+ platform: process.platform,
15
+ env: process.env,
16
+ exists(path) {
17
+ if (existsSync(path)) return true;
18
+ try {
19
+ return lstatSync(path).isSymbolicLink();
20
+ } catch {
21
+ return false;
22
+ }
23
+ },
24
+ };
25
+
26
+ interface Preset {
27
+ name: string;
28
+ icon: string;
29
+ /** Candidates in order. A bare name is looked up on PATH. */
30
+ commands(runtime: Runtime): string[];
31
+ /** Where to read the program icon when the command is a script. */
32
+ iconFrom?(runtime: Runtime): string[];
33
+ }
34
+
35
+ const home = (runtime: Runtime) => runtime.env.HOME ?? runtime.env.USERPROFILE ?? "";
36
+
37
+ function winCandidates(runtime: Runtime, local: string[], programFiles: string[]): string[] {
38
+ const out: string[] = [];
39
+ if (runtime.env.LOCALAPPDATA) out.push(...local.map((p) => `${runtime.env.LOCALAPPDATA}\\${p}`));
40
+ for (const root of [runtime.env.ProgramFiles, runtime.env["ProgramFiles(x86)"]]) {
41
+ if (root) out.push(...programFiles.map((p) => `${root}\\${p}`));
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function macApp(runtime: Runtime, app: string, binary: string): string[] {
47
+ return [`/Applications/${app}.app/${binary}`, `${home(runtime)}/Applications/${app}.app/${binary}`];
48
+ }
49
+
50
+ export const PRESETS: Record<PresetId, Preset> = {
51
+ fork: {
52
+ name: "Fork",
53
+ icon: "GitFork",
54
+ commands: (r) =>
55
+ r.platform === "win32"
56
+ ? winCandidates(r, ["Fork\\current\\Fork.exe", "Fork\\Fork.exe"], [])
57
+ : r.platform === "darwin"
58
+ ? [...macApp(r, "Fork", "Contents/Resources/fork_cli"), "fork"]
59
+ : [],
60
+ },
61
+ "github-desktop": {
62
+ name: "GitHub Desktop",
63
+ icon: "Github",
64
+ commands: (r) =>
65
+ r.platform === "win32"
66
+ ? [...winCandidates(r, ["GitHubDesktop\\bin\\github.bat"], []), "github"]
67
+ : r.platform === "darwin"
68
+ ? [...macApp(r, "GitHub Desktop", "Contents/Resources/app/static/github.sh"), "github"]
69
+ : ["github-desktop"],
70
+ iconFrom: (r) => (r.platform === "win32" ? winCandidates(r, ["GitHubDesktop\\GitHubDesktop.exe"], []) : []),
71
+ },
72
+ vscode: {
73
+ name: "VS Code",
74
+ icon: "Code",
75
+ commands: (r) =>
76
+ r.platform === "win32"
77
+ ? [
78
+ ...winCandidates(r, ["Programs\\Microsoft VS Code\\Code.exe"], ["Microsoft VS Code\\Code.exe"]),
79
+ "code",
80
+ ]
81
+ : r.platform === "darwin"
82
+ ? [...macApp(r, "Visual Studio Code", "Contents/Resources/app/bin/code"), "code"]
83
+ : ["code"],
84
+ },
85
+ cursor: {
86
+ name: "Cursor",
87
+ icon: "MousePointer2",
88
+ commands: (r) =>
89
+ r.platform === "win32"
90
+ ? [...winCandidates(r, ["Programs\\cursor\\Cursor.exe"], ["Cursor\\Cursor.exe"]), "cursor"]
91
+ : r.platform === "darwin"
92
+ ? [...macApp(r, "Cursor", "Contents/Resources/app/bin/cursor"), "cursor"]
93
+ : ["cursor"],
94
+ },
95
+ terminal: {
96
+ name: "Terminal",
97
+ icon: "SquareTerminal",
98
+ commands: (r) =>
99
+ r.platform === "win32"
100
+ ? [
101
+ ...(r.env.LOCALAPPDATA ? [`${r.env.LOCALAPPDATA}\\Microsoft\\WindowsApps\\wt.exe`] : []),
102
+ "wt",
103
+ ]
104
+ : r.platform === "darwin"
105
+ ? ["/usr/bin/open"]
106
+ : ["x-terminal-emulator", "gnome-terminal", "konsole"],
107
+ },
108
+ "file-manager": {
109
+ name: "File Manager",
110
+ icon: "Folder",
111
+ commands: (r) =>
112
+ r.platform === "win32" ? ["explorer.exe"] : r.platform === "darwin" ? ["/usr/bin/open"] : ["xdg-open"],
113
+ },
114
+ };
115
+
116
+ /** Arguments that differ by platform for one preset, where `{path}` alone is not enough. */
117
+ function presetArgs(preset: PresetId, runtime: Runtime): string {
118
+ if (preset === "terminal") {
119
+ if (runtime.platform === "win32") return `-d "${PATH_TOKEN}"`;
120
+ if (runtime.platform === "darwin") return `-a Terminal "${PATH_TOKEN}"`;
121
+ return `--working-directory="${PATH_TOKEN}"`;
122
+ }
123
+ return `"${PATH_TOKEN}"`;
124
+ }
125
+
126
+ function isAbsolute(path: string, platform: NodeJS.Platform) {
127
+ return platform === "win32" ? win32.isAbsolute(path) : posix.isAbsolute(path);
128
+ }
129
+
130
+ /** Finds a candidate on disk or on PATH, like a shell would. */
131
+ export function resolveCommand(candidates: readonly string[], runtime: Runtime): string | null {
132
+ const windows = runtime.platform === "win32";
133
+ const pathValue = runtime.env.PATH ?? runtime.env.Path ?? "";
134
+ const dirs = pathValue.split(windows ? ";" : ":").filter(Boolean);
135
+ for (const raw of candidates) {
136
+ const candidate = expandEnv(raw, runtime);
137
+ if (isAbsolute(candidate, runtime.platform)) {
138
+ if (runtime.exists(candidate)) return candidate;
139
+ continue;
140
+ }
141
+ const extensions =
142
+ windows && !win32.extname(candidate) ? [".exe", ".cmd", ".bat", ".com"] : [""];
143
+ for (const dir of dirs) {
144
+ for (const extension of extensions) {
145
+ const full = (windows ? win32 : posix).join(dir, candidate + extension);
146
+ if (runtime.exists(full)) return full;
147
+ }
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /** Expands `%NAME%` on Windows and a leading `~` everywhere. */
154
+ export function expandEnv(value: string, runtime: Runtime): string {
155
+ let out = value.trim();
156
+ if (out === "~" || out.startsWith("~/") || out.startsWith("~\\")) out = home(runtime) + out.slice(1);
157
+ if (runtime.platform === "win32") {
158
+ out = out.replace(/%([^%]+)%/g, (match, name: string) => {
159
+ const key = Object.keys(runtime.env).find((k) => k.toLowerCase() === name.toLowerCase());
160
+ return key ? (runtime.env[key] ?? match) : match;
161
+ });
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /** The menu entry after merging stored fields over the preset. */
167
+ export function describeEntry(entry: Entry, runtime: Runtime): App {
168
+ const preset = entry.preset ? PRESETS[entry.preset as PresetId] : undefined;
169
+ const defaults = {
170
+ name: preset?.name ?? "",
171
+ icon: preset?.icon ?? "AppWindow",
172
+ command: preset ? resolveCommand(preset.commands(runtime), runtime) : null,
173
+ args: preset ? presetArgs(entry.preset as PresetId, runtime) : `"${PATH_TOKEN}"`,
174
+ };
175
+ const command = entry.command ? resolveCommand([entry.command], runtime) : defaults.command;
176
+ return {
177
+ id: entry.id,
178
+ preset: entry.preset,
179
+ enabled: entry.enabled,
180
+ name: entry.name || defaults.name || entry.command || entry.id,
181
+ icon: entry.icon || defaults.icon,
182
+ command,
183
+ args: entry.args || defaults.args,
184
+ defaults,
185
+ };
186
+ }
187
+
188
+ export interface Command {
189
+ executable: string;
190
+ args: string[];
191
+ /** Windows batch files run through cmd.exe, which needs its own quoting. */
192
+ shell: boolean;
193
+ }
194
+
195
+ const CMD_SPECIAL = /[\s"&|^<>()!%]/;
196
+
197
+ function quoteForCmd(value: string): string {
198
+ if (!value || CMD_SPECIAL.test(value)) return `"${value.replace(/"/g, '""')}"`;
199
+ return value;
200
+ }
201
+
202
+ export function buildCommand(app: App, folder: string, runtime: Runtime): Command {
203
+ if (!app.command) throw new Error(`${app.name} is not installed`);
204
+ const args = splitArgs(app.args).map((arg) => arg.split(PATH_TOKEN).join(folder));
205
+ const script = runtime.platform === "win32" && /\.(cmd|bat)$/i.test(app.command);
206
+ if (script) {
207
+ return { executable: quoteForCmd(app.command), args: args.map(quoteForCmd), shell: true };
208
+ }
209
+ return { executable: app.command, args, shell: false };
210
+ }
211
+
212
+ /** Keys the daemon sets for itself. Child apps must not inherit them. */
213
+ const DAEMON_ENV = ["PASEO_NODE_ENV", "PASEO_DESKTOP_MANAGED", "PASEO_SUPERVISED", "ELECTRON_RUN_AS_NODE", "ELECTRON_NO_ATTACH_CONSOLE"];
214
+
215
+ export function launch(command: Command, cwd: string, runtime: Runtime = nodeRuntime): Promise<void> {
216
+ const env = { ...runtime.env };
217
+ for (const key of DAEMON_ENV) delete env[key];
218
+ return new Promise((resolve, reject) => {
219
+ const child = spawn(command.executable, command.args, {
220
+ cwd,
221
+ env,
222
+ detached: true,
223
+ stdio: "ignore",
224
+ shell: command.shell,
225
+ windowsHide: command.shell,
226
+ });
227
+ child.once("error", reject);
228
+ // explorer.exe exits with code 1 even on success, so only a spawn failure counts.
229
+ child.once("spawn", () => {
230
+ child.unref();
231
+ resolve();
232
+ });
233
+ });
234
+ }
@@ -0,0 +1,66 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ /**
4
+ * Reads PNG icons of Windows programs. Paths go in through an environment variable as
5
+ * base64 UTF-8 lines, and icons come back one base64 line per path, so no path text
6
+ * crosses the console code page.
7
+ */
8
+ const SCRIPT = `
9
+ $ErrorActionPreference = 'SilentlyContinue'
10
+ Add-Type -AssemblyName System.Drawing
11
+ $text = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:OPEN_WITH_ICON_PATHS))
12
+ foreach ($p in $text.Split([char]10)) {
13
+ $png = '-'
14
+ $icon = $null
15
+ if (Test-Path -LiteralPath $p) { $icon = [System.Drawing.Icon]::ExtractAssociatedIcon($p) }
16
+ if ($icon) {
17
+ $bmp = $icon.ToBitmap()
18
+ $ms = New-Object IO.MemoryStream
19
+ $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
20
+ $png = [Convert]::ToBase64String($ms.ToArray())
21
+ }
22
+ [Console]::Out.WriteLine($png)
23
+ }
24
+ `;
25
+
26
+ const cache = new Map<string, string | null>();
27
+
28
+ /** PNG data URLs keyed by path. Paths without an icon are missing from the result. */
29
+ export async function readIcons(paths: readonly string[]): Promise<Map<string, string>> {
30
+ const result = new Map<string, string>();
31
+ const missing = [...new Set(paths)].filter((path) => !cache.has(path));
32
+ if (process.platform === "win32" && missing.length > 0) {
33
+ const pngs = await runScript(missing).catch(() => null);
34
+ missing.forEach((path, index) => {
35
+ const png = pngs?.[index];
36
+ // A failed run is not cached, so the next menu load tries again.
37
+ if (pngs) cache.set(path, png ? `data:image/png;base64,${png}` : null);
38
+ });
39
+ }
40
+ for (const path of paths) {
41
+ const url = cache.get(path);
42
+ if (url) result.set(path, url);
43
+ }
44
+ return result;
45
+ }
46
+
47
+ function runScript(paths: readonly string[]): Promise<string[]> {
48
+ return new Promise((resolve, reject) => {
49
+ execFile(
50
+ "powershell.exe",
51
+ ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(SCRIPT, "utf16le").toString("base64")],
52
+ {
53
+ env: { ...process.env, OPEN_WITH_ICON_PATHS: Buffer.from(paths.join("\n"), "utf8").toString("base64") },
54
+ timeout: 15_000,
55
+ windowsHide: true,
56
+ maxBuffer: 8 * 1024 * 1024,
57
+ },
58
+ (error, stdout) => {
59
+ if (error) return reject(error);
60
+ const lines = stdout.split(/\r?\n/).filter(Boolean);
61
+ if (lines.length !== paths.length) return reject(new Error("Unexpected icon output"));
62
+ resolve(lines.map((line) => (line === "-" ? "" : line)));
63
+ },
64
+ );
65
+ });
66
+ }
@@ -0,0 +1,64 @@
1
+ import { stat } from "node:fs/promises";
2
+ import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
3
+ import type { PluginHandlerContext, PluginSettings } from "@getpaseo/plugin/server";
4
+ import { appsRpc, launchRpc, mergeEntries, settingsSchema, type App } from "../shared/open-with";
5
+ import { buildCommand, describeEntry, launch, nodeRuntime, PRESETS, resolveCommand } from "./apps";
6
+ import { readIcons } from "./icons";
7
+ import type { PresetId } from "../shared/open-with";
8
+
9
+ type Paseo = PluginHandlerContext["paseo"];
10
+ type Settings = PluginSettings<typeof settingsSchema>;
11
+
12
+ async function storedEntries(settings: Settings) {
13
+ const state = await settings.read();
14
+ return mergeEntries(state.status === "ready" ? state.values.entries : []);
15
+ }
16
+
17
+ /** Where to read each app's icon: the program, or a sibling exe when the program is a script. */
18
+ function iconSource(app: App): string | null {
19
+ if (app.preset && app.command === app.defaults.command) {
20
+ const iconFrom = PRESETS[app.preset as PresetId].iconFrom?.(nodeRuntime);
21
+ if (iconFrom) return resolveCommand(iconFrom, nodeRuntime);
22
+ }
23
+ return app.command && /\.exe$/i.test(app.command) ? app.command : null;
24
+ }
25
+
26
+ export async function listApps(settings: Settings): Promise<RpcOutput<typeof appsRpc>> {
27
+ const apps = (await storedEntries(settings)).map((entry) => describeEntry(entry, nodeRuntime));
28
+ const sources = apps.map(iconSource);
29
+ const icons = await readIcons(sources.filter((s): s is string => s !== null));
30
+ return {
31
+ apps: apps.map((app, index) => {
32
+ const image = sources[index] ? icons.get(sources[index]) : undefined;
33
+ return image ? { ...app, image } : app;
34
+ }),
35
+ };
36
+ }
37
+
38
+ async function workspaceFolder(paseo: Paseo, workspaceId: string): Promise<string | null> {
39
+ const ref = paseo.workspaces.ref(workspaceId);
40
+ const workspace = await ref.refresh();
41
+ return workspace?.workspaceDirectory ?? ref.directory ?? null;
42
+ }
43
+
44
+ export async function launchApp(
45
+ input: RpcInput<typeof launchRpc>,
46
+ settings: Settings,
47
+ paseo: Paseo,
48
+ ): Promise<RpcOutput<typeof launchRpc>> {
49
+ const entry = (await storedEntries(settings)).find((e) => e.id === input.appId);
50
+ if (!entry) return { ok: false, reason: "unknown-app" };
51
+ const app = describeEntry(entry, nodeRuntime);
52
+ if (!app.command) return { ok: false, reason: "not-installed" };
53
+
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
+ }
58
+ try {
59
+ await launch(buildCommand(app, folder, nodeRuntime), folder);
60
+ return { ok: true };
61
+ } catch (error) {
62
+ return { ok: false, reason: "failed", message: error instanceof Error ? error.message : String(error) };
63
+ }
64
+ }
@@ -0,0 +1,127 @@
1
+ import { defineRpc, defineSettings } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ /** Built-in apps, in their default menu order. The daemon finds where each one is installed. */
5
+ export const PRESET_IDS = ["fork", "github-desktop", "vscode", "cursor", "terminal", "file-manager"] as const;
6
+ export type PresetId = (typeof PRESET_IDS)[number];
7
+
8
+ /** Replaced with the workspace folder in each argument. */
9
+ export const PATH_TOKEN = "{path}";
10
+
11
+ export const entrySchema = z.object({
12
+ id: z.string().min(1).max(64),
13
+ /** A preset id, or "" for an app the user added. */
14
+ preset: z.string().max(64).default(""),
15
+ /** Empty fields fall back to the preset. */
16
+ name: z.string().trim().max(80).default(""),
17
+ icon: z.string().trim().max(64).default(""),
18
+ command: z.string().trim().max(1024).default(""),
19
+ args: z.string().trim().max(2048).default(""),
20
+ enabled: z.boolean().default(true),
21
+ });
22
+ export type Entry = z.infer<typeof entrySchema>;
23
+
24
+ export const settingsSchema = z.object({
25
+ entries: z.array(entrySchema).max(64).default([]),
26
+ });
27
+ export type Settings = z.infer<typeof settingsSchema>;
28
+
29
+ /** Host scope: each computer keeps its own app list, because install paths differ. */
30
+ export const openWithSettings = defineSettings({
31
+ id: "apps",
32
+ scope: "host",
33
+ version: 1,
34
+ schema: settingsSchema,
35
+ });
36
+
37
+ export function isPresetId(value: string): value is PresetId {
38
+ return (PRESET_IDS as readonly string[]).includes(value);
39
+ }
40
+
41
+ export function presetEntry(preset: PresetId): Entry {
42
+ return { id: preset, preset, name: "", icon: "", command: "", args: "", enabled: true };
43
+ }
44
+
45
+ /** Stored entries in their order, then every preset the user has not stored yet. */
46
+ export function mergeEntries(stored: readonly Entry[]): Entry[] {
47
+ const ids = new Set<string>();
48
+ const merged: Entry[] = [];
49
+ for (const entry of stored) {
50
+ if (ids.has(entry.id)) continue;
51
+ if (entry.preset && !isPresetId(entry.preset)) continue;
52
+ ids.add(entry.id);
53
+ merged.push(entry);
54
+ }
55
+ const storedPresets = new Set(merged.map((entry) => entry.preset));
56
+ for (const preset of PRESET_IDS) {
57
+ if (!storedPresets.has(preset) && !ids.has(preset)) merged.push(presetEntry(preset));
58
+ }
59
+ return merged;
60
+ }
61
+
62
+ /**
63
+ * Splits an argument line on spaces. Double quotes group a value with spaces.
64
+ * Backslashes stay literal, so Windows paths need no escaping.
65
+ */
66
+ export function splitArgs(line: string): string[] {
67
+ const args: string[] = [];
68
+ let current = "";
69
+ let quoted = false;
70
+ let started = false;
71
+ for (const char of line) {
72
+ if (char === '"') {
73
+ quoted = !quoted;
74
+ started = true;
75
+ } else if (!quoted && /\s/.test(char)) {
76
+ if (started) args.push(current);
77
+ current = "";
78
+ started = false;
79
+ } else {
80
+ current += char;
81
+ started = true;
82
+ }
83
+ }
84
+ if (started) args.push(current);
85
+ return args;
86
+ }
87
+
88
+ export const appSchema = z.object({
89
+ id: z.string(),
90
+ preset: z.string(),
91
+ enabled: z.boolean(),
92
+ name: z.string(),
93
+ icon: z.string(),
94
+ /** A PNG data URL of the program icon, when the daemon could read one. */
95
+ image: z.string().optional(),
96
+ /** The program that runs, or null when it is not installed. */
97
+ command: z.string().nullable(),
98
+ args: z.string(),
99
+ defaults: z.object({
100
+ name: z.string(),
101
+ icon: z.string(),
102
+ command: z.string().nullable(),
103
+ args: z.string(),
104
+ }),
105
+ });
106
+ export type App = z.infer<typeof appSchema>;
107
+
108
+ export const appsRpc = defineRpc({
109
+ name: "open-with.apps",
110
+ input: z.object({}),
111
+ output: z.object({ apps: z.array(appSchema) }),
112
+ });
113
+
114
+ export const launchFailureSchema = z.enum(["unknown-app", "not-installed", "no-folder", "failed"]);
115
+ export type LaunchFailure = z.infer<typeof launchFailureSchema>;
116
+
117
+ export const launchRpc = defineRpc({
118
+ name: "open-with.launch",
119
+ input: z.object({
120
+ workspaceId: z.string().min(1).max(512),
121
+ appId: z.string().min(1).max(64),
122
+ }),
123
+ output: z.discriminatedUnion("ok", [
124
+ z.object({ ok: z.literal(true) }),
125
+ z.object({ ok: z.literal(false), reason: launchFailureSchema, message: z.string().optional() }),
126
+ ]),
127
+ });