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:
@@ -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} />;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user