Open workspace files in desktop targets

This commit is contained in:
Mohamed Boudra
2026-06-03 18:34:24 +08:00
committed by GitHub
parent 45f412456c
commit 3681fd132b
30 changed files with 1830 additions and 822 deletions
@@ -0,0 +1,71 @@
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 interface DesktopOpenTarget {
id: string;
label: string;
kind: DesktopOpenTargetKind;
}
export interface OpenDesktopTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: DesktopOpenMode;
}
interface AvailableDesktopEditorBridge {
listTargets: NonNullable<DesktopEditorBridge["listTargets"]>;
openTarget: NonNullable<DesktopEditorBridge["openTarget"]>;
}
function getDesktopEditorBridge(): AvailableDesktopEditorBridge | null {
const bridge = getDesktopHost()?.editor;
if (!bridge?.listTargets || !bridge.openTarget) {
return null;
}
return {
listTargets: bridge.listTargets,
openTarget: bridge.openTarget,
};
}
export function hasDesktopOpenTargetsBridge(): boolean {
return getDesktopEditorBridge() !== null;
}
export async function listDesktopOpenTargets(): Promise<DesktopOpenTarget[]> {
const bridge = getDesktopEditorBridge();
if (!bridge) {
return [];
}
return await bridge.listTargets();
}
export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise<void> {
const bridge = getDesktopEditorBridge();
if (!bridge) {
throw new Error("Desktop editor bridge is unavailable");
}
await bridge.openTarget(input);
}
export function useDesktopOpenTargets(input: { isLocalExecution: boolean }) {
const hasBridge = hasDesktopOpenTargetsBridge();
const canListTargets = hasBridge && input.isLocalExecution;
const query = useQuery({
queryKey: ["desktop-open-targets"],
enabled: canListTargets,
staleTime: 60_000,
retry: false,
queryFn: listDesktopOpenTargets,
});
return {
targets: query.data ?? [],
isAvailable: canListTargets,
};
}
@@ -0,0 +1,11 @@
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);
});
});
@@ -0,0 +1,15 @@
export type EditorTargetId = string;
const KNOWN_EDITOR_TARGET_IDS: ReadonlySet<string> = new Set([
"cursor",
"vscode",
"webstorm",
"zed",
"finder",
"explorer",
"file-manager",
]);
export function isKnownEditorTargetId(editorId: EditorTargetId): boolean {
return KNOWN_EDITOR_TARGET_IDS.has(editorId);
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
resolveWorkspaceFilePaths,
workspaceFileLocationsEqual,
} from ".";
@@ -61,3 +62,93 @@ describe("workspace file tab targets", () => {
).toBe(false);
});
});
describe("resolveWorkspaceFilePaths", () => {
it("joins workspace-relative paths against the workspace root", () => {
expect(
resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("derives the relative path for an absolute file inside the workspace", () => {
expect(
resolveWorkspaceFilePaths({
path: "/Users/me/repo/src/app.ts",
workspaceRoot: "/Users/me/repo",
}),
).toEqual({
absolutePath: "/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("keeps the absolute path but drops the relative path when outside the workspace", () => {
expect(
resolveWorkspaceFilePaths({
path: "/etc/hosts",
workspaceRoot: "/Users/me/repo",
}),
).toEqual({
absolutePath: "/etc/hosts",
relativePath: null,
});
});
it("normalizes Windows separators in the file path", () => {
expect(
resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("normalizes dot segments in workspace-relative paths", () => {
expect(
resolveWorkspaceFilePaths({ path: "./src/../app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/app.ts",
relativePath: "app.ts",
});
});
it("rejects workspace-relative paths that escape the workspace", () => {
expect(
resolveWorkspaceFilePaths({ path: "../outside.ts", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
expect(
resolveWorkspaceFilePaths({ path: "src/../../outside.ts", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
});
it("derives the relative path for a Windows absolute file inside the workspace", () => {
expect(
resolveWorkspaceFilePaths({
path: "C:\\Users\\me\\repo\\src\\app.ts",
workspaceRoot: "C:\\Users\\me\\repo",
}),
).toEqual({
absolutePath: "C:/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
});
it("returns null for home-relative paths that cannot be anchored", () => {
expect(
resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
});
it("returns null when the workspace root is not absolute", () => {
expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "repo" })).toBeNull();
});
it("returns null when the resolved file equals the workspace root", () => {
expect(
resolveWorkspaceFilePaths({ path: "/Users/me/repo", workspaceRoot: "/Users/me/repo" }),
).toBeNull();
});
});
@@ -1,3 +1,5 @@
import { isAbsolutePath } from "@/utils/path";
export type OpenFileDisposition = "main" | "side";
export interface WorkspaceFileLocation {
@@ -57,3 +59,103 @@ function normalizeLineNumber(value: number | null | undefined): number | undefin
? Math.floor(value)
: undefined;
}
function trimTrailingSlashes(value: string): string {
if (value === "/" || /^\/+$/.test(value)) {
return "/";
}
if (/^[A-Za-z]:\/+$/.test(value)) {
return `${value.slice(0, 2)}/`;
}
return value.replace(/\/+$/, "");
}
function normalizePathSegments(value: string, rejectEscape: boolean): string | null {
const segments: string[] = [];
for (const segment of value.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
if (rejectEscape) {
return null;
}
continue;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/");
}
function normalizeAbsolutePath(value: string): string | null {
const normalizedInput = trimTrailingSlashes(value);
if (!isAbsolutePath(normalizedInput)) {
return null;
}
const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput);
if (drivePath) {
const normalizedBody = normalizePathSegments(drivePath[2], false);
return trimTrailingSlashes(`${drivePath[1]}/${normalizedBody}`);
}
const prefix = normalizedInput.startsWith("//") ? "//" : "/";
const normalizedBody = normalizePathSegments(normalizedInput.replace(/^\/+/, ""), false);
return trimTrailingSlashes(`${prefix}${normalizedBody}`);
}
function normalizeRelativePath(value: string): string | null {
const normalized = normalizePathSegments(value.replace(/^\/+/, ""), true);
return normalized || null;
}
export interface ResolvedWorkspaceFilePaths {
/** Absolute path on the host, suitable for opening in an editor / file manager. */
absolutePath: string;
/** Path relative to the workspace root, or null when the file lives outside it. */
relativePath: string | null;
}
/**
* Resolves a file tab's path (which may be workspace-relative) against the workspace
* root. Returns null when an absolute host path cannot be derived — e.g. a `~`-relative
* path or a relative path with no workspace root to anchor it.
*/
export function resolveWorkspaceFilePaths(input: {
path: string;
workspaceRoot: string;
}): ResolvedWorkspaceFilePaths | null {
const filePath = input.path.trim().replace(/\\/g, "/");
const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
if (!filePath || !workspaceRoot) {
return null;
}
if (isAbsolutePath(filePath)) {
const normalizedFile = normalizeAbsolutePath(filePath);
if (!normalizedFile) {
return null;
}
if (normalizedFile === workspaceRoot) {
return null;
}
const relativePath = normalizedFile.startsWith(`${workspaceRoot}/`)
? normalizedFile.slice(workspaceRoot.length + 1)
: null;
return { absolutePath: normalizedFile, relativePath };
}
if (filePath.startsWith("~")) {
return null;
}
const relativePath = normalizeRelativePath(filePath);
if (!relativePath) {
return null;
}
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
}
@@ -0,0 +1,150 @@
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 },
];
const checkoutStatus = {
isGit: true,
remoteUrl: "git@github.com:getpaseo/paseo.git",
currentBranch: "main",
};
describe("planWorkspaceOpenTargets", () => {
it("plans editor targets with active-file absolute path and cwd", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 },
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets[0]).toMatchObject({
source: "desktop",
id: "vscode",
openInput: { editorId: "vscode", path: "/repo/src/app.ts", cwd: "/repo" },
});
});
it("plans file-manager targets with active-file absolute path and reveal mode", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts" },
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets[1]).toMatchObject({
source: "desktop",
id: "finder",
openInput: { editorId: "finder", path: "/repo/src/app.ts", mode: "reveal" },
});
});
it("plans no active file as opening the workspace folder", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets[0]).toMatchObject({
source: "desktop",
id: "vscode",
openInput: { editorId: "vscode", path: "/repo" },
});
expect(targets[1]).toMatchObject({
source: "desktop",
id: "finder",
openInput: { editorId: "finder", path: "/repo" },
});
});
it("passes custom target ids through as strings", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts" },
desktopTargets: [{ id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" }],
canUseDesktopBridge: true,
isLocalExecution: true,
});
expect(targets).toEqual([
{
source: "desktop",
id: "script:open-in-nvim",
label: "Open in Neovim",
editorId: "script:open-in-nvim",
openInput: {
editorId: "script:open-in-nvim",
path: "/repo/src/app.ts",
cwd: "/repo",
},
},
]);
});
it("keeps GitHub target independent and uses blob and tree URLs", () => {
const blobTargets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 },
desktopTargets: [],
canUseDesktopBridge: false,
isLocalExecution: false,
checkoutStatus,
});
const treeTargets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets: [],
canUseDesktopBridge: false,
isLocalExecution: false,
checkoutStatus,
});
expect(blobTargets).toEqual([
{
source: "github",
id: "github",
label: "GitHub",
url: "https://github.com/getpaseo/paseo/blob/main/src/app.ts#L3-L5",
},
]);
expect(treeTargets).toEqual([
{
source: "github",
id: "github",
label: "GitHub",
url: "https://github.com/getpaseo/paseo/tree/main",
},
]);
});
it("suppresses desktop targets when Electron bridge is unavailable", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets,
canUseDesktopBridge: false,
isLocalExecution: true,
checkoutStatus,
});
expect(targets.map((target) => target.id)).toEqual(["github"]);
});
it("suppresses desktop targets for remote execution paths", () => {
const targets = planWorkspaceOpenTargets({
workspaceDirectory: "/repo",
desktopTargets,
canUseDesktopBridge: true,
isLocalExecution: false,
checkoutStatus,
});
expect(targets.map((target) => target.id)).toEqual(["github"]);
});
});
@@ -0,0 +1,146 @@
import { buildGitHubBlobUrl, buildGitHubBranchTreeUrl } from "@/git/github-url";
import type { DesktopOpenTarget, OpenDesktopTargetInput } from "@/workspace/desktop-open-targets";
import {
type ResolvedWorkspaceFilePaths,
resolveWorkspaceFilePaths,
type WorkspaceFileLocation,
} from "@/workspace/file-open";
interface CheckoutStatusForOpenTarget {
isGit: boolean;
remoteUrl?: string | null;
currentBranch?: string | null;
}
export interface PlannedDesktopOpenTarget {
source: "desktop";
id: string;
label: string;
editorId: string;
openInput: OpenDesktopTargetInput;
}
export interface PlannedGitHubOpenTarget {
source: "github";
id: "github";
label: "GitHub";
url: string;
}
export type PlannedWorkspaceOpenTarget = PlannedDesktopOpenTarget | PlannedGitHubOpenTarget;
export interface PlanWorkspaceOpenTargetsInput {
workspaceDirectory: string;
activeFile?: WorkspaceFileLocation | null;
resolvedActiveFile?: ResolvedWorkspaceFilePaths | null;
desktopTargets: readonly DesktopOpenTarget[];
canUseDesktopBridge: boolean;
isLocalExecution: boolean;
checkoutStatus?: CheckoutStatusForOpenTarget | null;
}
function resolveActiveFileForOpenTargets(
input: Pick<
PlanWorkspaceOpenTargetsInput,
"activeFile" | "resolvedActiveFile" | "workspaceDirectory"
>,
): ResolvedWorkspaceFilePaths | null {
if (input.resolvedActiveFile !== undefined) {
return input.resolvedActiveFile;
}
return input.activeFile
? resolveWorkspaceFilePaths({
path: input.activeFile.path,
workspaceRoot: input.workspaceDirectory,
})
: null;
}
function planDesktopOpenTargets(input: {
workspaceDirectory: string;
resolvedFile: ResolvedWorkspaceFilePaths | null;
desktopTargets: readonly DesktopOpenTarget[];
canUseDesktopBridge: boolean;
isLocalExecution: boolean;
}): PlannedDesktopOpenTarget[] {
if (!input.canUseDesktopBridge || !input.isLocalExecution) {
return [];
}
return input.desktopTargets.map((target) => {
if (!input.resolvedFile) {
return {
source: "desktop",
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,
},
};
}
return {
source: "desktop",
id: target.id,
label: target.label,
editorId: target.id,
openInput: {
editorId: target.id,
path: input.resolvedFile.absolutePath,
mode: "reveal",
},
};
});
}
function planGitHubOpenTarget(input: {
activeFile?: WorkspaceFileLocation | null;
resolvedFile: ResolvedWorkspaceFilePaths | null;
checkoutStatus?: CheckoutStatusForOpenTarget | null;
}): PlannedGitHubOpenTarget | null {
if (!input.checkoutStatus?.isGit) {
return null;
}
const url = input.resolvedFile?.relativePath
? buildGitHubBlobUrl({
remoteUrl: input.checkoutStatus.remoteUrl,
branch: input.checkoutStatus.currentBranch,
path: input.resolvedFile.relativePath,
lineStart: input.activeFile?.lineStart,
lineEnd: input.activeFile?.lineEnd,
})
: buildGitHubBranchTreeUrl({
remoteUrl: input.checkoutStatus.remoteUrl,
branch: input.checkoutStatus.currentBranch,
});
if (!url) {
return null;
}
return {
source: "github",
id: "github",
label: "GitHub",
url,
};
}
export function planWorkspaceOpenTargets(
input: PlanWorkspaceOpenTargetsInput,
): PlannedWorkspaceOpenTarget[] {
const resolvedFile = resolveActiveFileForOpenTargets(input);
const desktopTargets = planDesktopOpenTargets({ ...input, resolvedFile });
const githubTarget = planGitHubOpenTarget({ ...input, resolvedFile });
return githubTarget ? [...desktopTargets, githubTarget] : desktopTargets;
}