Open files in more installed editors (#2119)

* feat(desktop): open files in more installed editors

Detect bundled app commands as well as PATH launchers and preserve file positions through editor-specific launches.

* fix(desktop): preserve editor target compatibility

Keep existing file-manager preference ids, retain project context for file launches, and recognize Windows 64-bit IDE commands.
This commit is contained in:
Mohamed Boudra
2026-07-16 14:39:55 +02:00
committed by GitHub
parent 3e8dce7d7c
commit f4509fe044
50 changed files with 1665 additions and 1051 deletions
@@ -1,37 +0,0 @@
import { SquareTerminal } from "lucide-react-native";
import { useMemo } from "react";
import { Image, type ImageSourcePropType } from "react-native";
import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets";
interface EditorAppIconProps {
editorId: EditorTargetId;
size?: number;
color?: string;
}
/* eslint-disable @typescript-eslint/no-require-imports */
const EDITOR_APP_IMAGES: Record<string, ImageSourcePropType> = {
cursor: require("../../../assets/images/editor-apps/cursor.png"),
vscode: require("../../../assets/images/editor-apps/vscode.png"),
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
zed: require("../../../assets/images/editor-apps/zed.png"),
antigravity: require("../../../assets/images/editor-apps/antigravity.png"),
finder: require("../../../assets/images/editor-apps/finder.png"),
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
};
/* eslint-enable @typescript-eslint/no-require-imports */
export function hasBundledEditorAppIcon(editorId: EditorTargetId): boolean {
return isKnownEditorTargetId(editorId) && EDITOR_APP_IMAGES[editorId] !== undefined;
}
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
const source = EDITOR_APP_IMAGES[editorId];
if (!source) {
return <SquareTerminal size={size} color={color} />;
}
return <Image source={source} style={imageStyle} resizeMode="contain" />;
}
@@ -0,0 +1,27 @@
import { Folder, SquareTerminal } from "lucide-react-native";
import { useMemo } from "react";
import { Image } from "react-native";
import type { DesktopOpenTargetIcon } from "@/workspace/desktop-open-targets";
interface EditorTargetIconProps {
icon: DesktopOpenTargetIcon;
size?: number;
color?: string;
}
export function EditorTargetIcon({ icon, size = 16, color }: EditorTargetIconProps) {
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
const imageSource = useMemo(
() => (icon.kind === "image" ? { uri: icon.dataUrl } : undefined),
[icon],
);
if (imageSource) {
return <Image source={imageSource} style={imageStyle} resizeMode="contain" />;
}
if (icon.kind === "symbol" && icon.name === "folder") {
return <Folder size={size} color={color} />;
}
return <SquareTerminal size={size} color={color} />;
}
+5 -3
View File
@@ -66,13 +66,15 @@ export interface DesktopEditorTargetDescriptor {
id: string;
label: string;
kind: "editor" | "file-manager";
icon: { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" };
}
export interface DesktopEditorOpenTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
}
export interface DesktopEditorBridge {
@@ -1,7 +1,8 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { EditorTargetId } from "@/workspace/editor-targets";
type EditorTargetId = string;
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
@@ -10,7 +10,7 @@ import {
import { useMutation } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
import { EditorTargetIcon } from "@/components/icons/editor-target-icon";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
DropdownMenu,
@@ -46,7 +46,7 @@ interface OpenTarget {
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedEditorAppIcon = withUnistyles(EditorAppIcon);
const ThemedEditorTargetIcon = withUnistyles(EditorTargetIcon);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedCheckIcon = withUnistyles(Check);
@@ -134,7 +134,9 @@ export function WorkspaceOpenInEditorButton({
return {
id: target.id,
label: target.label,
icon: <ThemedEditorAppIcon editorId={target.id} size={16} uniProps={mutedColorMapping} />,
icon: (
<ThemedEditorTargetIcon icon={target.icon} size={16} uniProps={mutedColorMapping} />
),
onOpen: () => openDesktopTarget(target.openInput),
};
}),
@@ -2,19 +2,23 @@ import { useQuery } from "@tanstack/react-query";
import { getDesktopHost, type DesktopEditorBridge } from "@/desktop/host";
export type DesktopOpenTargetKind = "editor" | "file-manager";
export type DesktopOpenMode = "open" | "reveal";
export type DesktopOpenTargetIcon =
| { kind: "image"; dataUrl: string }
| { kind: "symbol"; name: "folder" | "terminal" };
export interface DesktopOpenTarget {
id: string;
label: string;
kind: DesktopOpenTargetKind;
icon: DesktopOpenTargetIcon;
}
export interface OpenDesktopTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: DesktopOpenMode;
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
}
interface AvailableDesktopEditorBridge {
@@ -1,11 +0,0 @@
import { describe, expect, it } from "vitest";
import { isKnownEditorTargetId } from "./editor-targets";
describe("editor target ids", () => {
it("recognizes built-ins without typing custom ids out of the system", () => {
const customEditorId: string = "script:open-in-nvim";
expect(isKnownEditorTargetId("vscode")).toBe(true);
expect(isKnownEditorTargetId(customEditorId)).toBe(false);
});
});
@@ -1,16 +0,0 @@
export type EditorTargetId = string;
const KNOWN_EDITOR_TARGET_IDS: ReadonlySet<string> = new Set([
"cursor",
"vscode",
"webstorm",
"zed",
"antigravity",
"finder",
"explorer",
"file-manager",
]);
export function isKnownEditorTargetId(editorId: EditorTargetId): boolean {
return KNOWN_EDITOR_TARGET_IDS.has(editorId);
}
@@ -2,8 +2,18 @@ import { describe, expect, it } from "vitest";
import { planWorkspaceOpenTargets } from "./open-target-planner";
const desktopTargets = [
{ id: "vscode", label: "VS Code", kind: "editor" as const },
{ id: "finder", label: "Finder", kind: "file-manager" as const },
{
id: "vscode",
label: "VS Code",
kind: "editor" as const,
icon: { kind: "symbol" as const, name: "terminal" as const },
},
{
id: "finder",
label: "Finder",
kind: "file-manager" as const,
icon: { kind: "symbol" as const, name: "folder" as const },
},
];
const checkoutStatus = {
@@ -25,7 +35,12 @@ describe("planWorkspaceOpenTargets", () => {
expect(targets[0]).toMatchObject({
source: "desktop",
id: "vscode",
openInput: { editorId: "vscode", path: "/repo/src/app.ts", cwd: "/repo" },
openInput: {
editorId: "vscode",
workspacePath: "/repo",
filePath: "/repo/src/app.ts",
line: 3,
},
});
});
@@ -41,7 +56,11 @@ describe("planWorkspaceOpenTargets", () => {
expect(targets[1]).toMatchObject({
source: "desktop",
id: "finder",
openInput: { editorId: "finder", path: "/repo/src/app.ts", mode: "reveal" },
openInput: {
editorId: "finder",
workspacePath: "/repo",
filePath: "/repo/src/app.ts",
},
});
});
@@ -56,12 +75,12 @@ describe("planWorkspaceOpenTargets", () => {
expect(targets[0]).toMatchObject({
source: "desktop",
id: "vscode",
openInput: { editorId: "vscode", path: "/repo" },
openInput: { editorId: "vscode", workspacePath: "/repo" },
});
expect(targets[1]).toMatchObject({
source: "desktop",
id: "finder",
openInput: { editorId: "finder", path: "/repo" },
openInput: { editorId: "finder", workspacePath: "/repo" },
});
});
@@ -69,7 +88,14 @@ describe("planWorkspaceOpenTargets", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts" },
desktopTargets: [{ id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" }],
desktopTargets: [
{
id: "script:open-in-nvim",
label: "Open in Neovim",
kind: "editor",
icon: { kind: "symbol", name: "terminal" },
},
],
canUseDesktopBridge: true,
isLocalExecution: true,
});
@@ -80,10 +106,11 @@ describe("planWorkspaceOpenTargets", () => {
id: "script:open-in-nvim",
label: "Open in Neovim",
editorId: "script:open-in-nvim",
icon: { kind: "symbol", name: "terminal" },
openInput: {
editorId: "script:open-in-nvim",
path: "/repo/src/app.ts",
cwd: "/repo",
workspacePath: "/repo",
filePath: "/repo/src/app.ts",
},
},
]);
@@ -17,6 +17,7 @@ export interface PlannedDesktopOpenTarget {
id: string;
label: string;
editorId: string;
icon: DesktopOpenTarget["icon"];
openInput: OpenDesktopTargetInput;
}
@@ -58,6 +59,7 @@ function resolveActiveFileForOpenTargets(
function planDesktopOpenTargets(input: {
workspaceDirectory: string;
activeFile?: WorkspaceFileLocation | null;
resolvedFile: ResolvedWorkspaceFilePaths | null;
desktopTargets: readonly DesktopOpenTarget[];
canUseDesktopBridge: boolean;
@@ -74,20 +76,8 @@ function planDesktopOpenTargets(input: {
id: target.id,
label: target.label,
editorId: target.id,
openInput: { editorId: target.id, path: input.workspaceDirectory },
};
}
if (target.kind === "editor") {
return {
source: "desktop",
id: target.id,
label: target.label,
editorId: target.id,
openInput: {
editorId: target.id,
path: input.resolvedFile.absolutePath,
cwd: input.workspaceDirectory,
},
icon: target.icon,
openInput: { editorId: target.id, workspacePath: input.workspaceDirectory },
};
}
return {
@@ -95,10 +85,12 @@ function planDesktopOpenTargets(input: {
id: target.id,
label: target.label,
editorId: target.id,
icon: target.icon,
openInput: {
editorId: target.id,
path: input.resolvedFile.absolutePath,
mode: "reveal",
workspacePath: input.workspaceDirectory,
filePath: input.resolvedFile.absolutePath,
...(input.activeFile?.lineStart ? { line: input.activeFile.lineStart } : {}),
},
};
});