Open workspace files in desktop targets
This commit is contained in:
@@ -1,11 +1,7 @@
|
||||
import { SquareTerminal } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { Image, type ImageSourcePropType } from "react-native";
|
||||
import {
|
||||
isKnownEditorTargetId,
|
||||
type EditorTargetId,
|
||||
type KnownEditorTargetId,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets";
|
||||
|
||||
interface EditorAppIconProps {
|
||||
editorId: EditorTargetId;
|
||||
@@ -14,7 +10,7 @@ interface EditorAppIconProps {
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
|
||||
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"),
|
||||
@@ -25,15 +21,16 @@ const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId {
|
||||
return isKnownEditorTargetId(editorId);
|
||||
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]);
|
||||
if (!hasBundledEditorAppIcon(editorId)) {
|
||||
const source = EDITOR_APP_IMAGES[editorId];
|
||||
if (!source) {
|
||||
return <SquareTerminal size={size} color={color} />;
|
||||
}
|
||||
|
||||
return <Image source={EDITOR_APP_IMAGES[editorId]} style={imageStyle} resizeMode="contain" />;
|
||||
return <Image source={source} style={imageStyle} resizeMode="contain" />;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,24 @@ export interface DesktopOpenerBridge {
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopEditorTargetDescriptor {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "editor" | "file-manager";
|
||||
}
|
||||
|
||||
export interface DesktopEditorOpenTargetInput {
|
||||
editorId: string;
|
||||
path: string;
|
||||
cwd?: string;
|
||||
mode?: "open" | "reveal";
|
||||
}
|
||||
|
||||
export interface DesktopEditorBridge {
|
||||
listTargets?: () => Promise<DesktopEditorTargetDescriptor[]>;
|
||||
openTarget?: (input: DesktopEditorOpenTargetInput) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopWebUtilsBridge {
|
||||
getPathForFile?: (file: File) => string;
|
||||
}
|
||||
@@ -111,6 +129,7 @@ export interface DesktopHostBridge {
|
||||
dialog?: DesktopDialogBridge;
|
||||
notification?: DesktopNotificationBridge;
|
||||
opener?: DesktopOpenerBridge;
|
||||
editor?: DesktopEditorBridge;
|
||||
webUtils?: DesktopWebUtilsBridge;
|
||||
menu?: DesktopMenuBridge;
|
||||
browser?: DesktopBrowserBridge;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url";
|
||||
import {
|
||||
buildGitHubBlobUrl,
|
||||
buildGitHubBranchTreeUrl,
|
||||
parseGitHubRepoFromRemote,
|
||||
} from "./github-url";
|
||||
|
||||
describe("parseGitHubRepoFromRemote", () => {
|
||||
it.each([
|
||||
@@ -51,3 +55,78 @@ describe("buildGitHubBranchTreeUrl", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGitHubBlobUrl", () => {
|
||||
it("builds a blob URL for a file path", () => {
|
||||
expect(
|
||||
buildGitHubBlobUrl({
|
||||
remoteUrl: "git@github.com:acme/repo.git",
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
|
||||
});
|
||||
|
||||
it("appends a single-line anchor", () => {
|
||||
expect(
|
||||
buildGitHubBlobUrl({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
lineStart: 12,
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12");
|
||||
});
|
||||
|
||||
it("appends a line range anchor", () => {
|
||||
expect(
|
||||
buildGitHubBlobUrl({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20");
|
||||
});
|
||||
|
||||
it("strips leading slashes and encodes path segments", () => {
|
||||
expect(
|
||||
buildGitHubBlobUrl({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "/src/a b/c#d.ts",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts");
|
||||
});
|
||||
|
||||
it("normalizes harmless dot segments in the blob path", () => {
|
||||
expect(
|
||||
buildGitHubBlobUrl({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "./src/../index.ts",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/index.ts");
|
||||
});
|
||||
|
||||
it("returns null for blob paths that escape above the repo root", () => {
|
||||
expect(
|
||||
buildGitHubBlobUrl({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "../outside.ts",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the path is missing", () => {
|
||||
expect(
|
||||
buildGitHubBlobUrl({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "main",
|
||||
path: "",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,3 +20,50 @@ export function buildGitHubBranchTreeUrl(input: {
|
||||
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
|
||||
return `https://github.com/${repo}/tree/${encodedBranch}`;
|
||||
}
|
||||
|
||||
function normalizeGitHubBlobPath(path: string | null | undefined): string | null {
|
||||
const segments: string[] = [];
|
||||
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
for (const segment of trimmed.split("/")) {
|
||||
if (!segment || segment === ".") {
|
||||
continue;
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
segments.pop();
|
||||
continue;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.join("/") || null;
|
||||
}
|
||||
|
||||
export function buildGitHubBlobUrl(input: {
|
||||
remoteUrl: string | null | undefined;
|
||||
branch: string | null | undefined;
|
||||
path: string | null | undefined;
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
}): string | null {
|
||||
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
|
||||
const branch = input.branch?.trim();
|
||||
const filePath = normalizeGitHubBlobPath(input.path);
|
||||
if (!repo || !branch || branch === "HEAD" || !filePath) {
|
||||
return null;
|
||||
}
|
||||
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
|
||||
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
||||
let url = `https://github.com/${repo}/blob/${encodedBranch}/${encodedPath}`;
|
||||
if (input.lineStart && input.lineStart > 0) {
|
||||
url += `#L${input.lineStart}`;
|
||||
if (input.lineEnd && input.lineEnd > input.lineStart) {
|
||||
url += `-L${input.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ describe("resolvePreferredEditorId", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps custom script target ids as plain strings", () => {
|
||||
expect(resolvePreferredEditorId(["script:open-in-nvim", "cursor"], "script:open-in-nvim")).toBe(
|
||||
"script:open-in-nvim",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no editors are available", () => {
|
||||
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { EditorTargetIdSchema, type EditorTargetId } from "@getpaseo/protocol/messages";
|
||||
import type { EditorTargetId } from "@/workspace/editor-targets";
|
||||
|
||||
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
|
||||
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
|
||||
@@ -11,8 +11,7 @@ async function loadPreferredEditor(): Promise<EditorTargetId | null> {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
const parsed = EditorTargetIdSchema.safeParse(stored);
|
||||
return parsed.success ? parsed.data : null;
|
||||
return stored.trim() || null;
|
||||
}
|
||||
|
||||
export function resolvePreferredEditorId(
|
||||
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { EditorTargetDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import {
|
||||
@@ -21,18 +20,20 @@ import {
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||
import { buildGitHubBranchTreeUrl } from "@/git/github-url";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
import { resolveWorkspaceFilePaths, type WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { planWorkspaceOpenTargets } from "@/workspace/open-target-planner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
activeFile?: WorkspaceFileLocation | null;
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
@@ -40,7 +41,6 @@ interface OpenTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
requiresLocalDaemon: boolean;
|
||||
onOpen: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -80,95 +80,71 @@ function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemP
|
||||
export function WorkspaceOpenInEditorButton({
|
||||
serverId,
|
||||
cwd,
|
||||
activeFile,
|
||||
hideLabels,
|
||||
}: WorkspaceOpenInEditorButtonProps) {
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||
const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } =
|
||||
useDesktopOpenTargets({
|
||||
isLocalExecution: isLocalDaemon,
|
||||
});
|
||||
|
||||
const shouldQueryWorkspace =
|
||||
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||
const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon;
|
||||
|
||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||
queryKey: ["available-editors", serverId],
|
||||
enabled: shouldLoadEditorTargets,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const payload = await client.listAvailableEditors();
|
||||
return payload.error ? [] : payload.editors;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const availableEditors = useMemo(
|
||||
() => availableEditorsQuery.data ?? [],
|
||||
[availableEditorsQuery.data],
|
||||
const resolvedFile = useMemo(
|
||||
() =>
|
||||
activeFile ? resolveWorkspaceFilePaths({ path: activeFile.path, workspaceRoot: cwd }) : null,
|
||||
[activeFile, cwd],
|
||||
);
|
||||
const activeFileName = useMemo(
|
||||
() => resolvedFile?.absolutePath.split("/").findLast(Boolean) ?? null,
|
||||
[resolvedFile],
|
||||
);
|
||||
|
||||
const canResolveWorkspace = isWeb && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||
const shouldQueryCheckout = canResolveWorkspace && isConnected;
|
||||
|
||||
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
cwd: shouldQueryWorkspace ? cwd : "",
|
||||
cwd: shouldQueryCheckout ? cwd : "",
|
||||
});
|
||||
|
||||
const editorTargets = useMemo<OpenTarget[]>(
|
||||
const targets = useMemo<OpenTarget[]>(
|
||||
() =>
|
||||
availableEditors.map((editor) => ({
|
||||
id: editor.id,
|
||||
label: editor.label,
|
||||
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
||||
requiresLocalDaemon: true,
|
||||
onOpen: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.openInEditor(cwd, editor.id);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
},
|
||||
})),
|
||||
[availableEditors, client, cwd],
|
||||
);
|
||||
|
||||
const githubTarget = useMemo<OpenTarget | null>(() => {
|
||||
if (!checkoutStatus?.isGit) {
|
||||
return null;
|
||||
}
|
||||
const url = buildGitHubBranchTreeUrl({
|
||||
remoteUrl: checkoutStatus.remoteUrl,
|
||||
branch: checkoutStatus.currentBranch,
|
||||
});
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: "github",
|
||||
label: "GitHub",
|
||||
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
||||
requiresLocalDaemon: false,
|
||||
onOpen: () => openExternalUrl(url),
|
||||
};
|
||||
}, [checkoutStatus]);
|
||||
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
filterTargetsForDaemonLocation(
|
||||
githubTarget ? [...editorTargets, githubTarget] : editorTargets,
|
||||
{
|
||||
isLocalDaemon,
|
||||
},
|
||||
),
|
||||
[editorTargets, githubTarget, isLocalDaemon],
|
||||
planWorkspaceOpenTargets({
|
||||
workspaceDirectory: cwd,
|
||||
activeFile,
|
||||
resolvedActiveFile: resolvedFile,
|
||||
desktopTargets: desktopOpenTargets,
|
||||
canUseDesktopBridge: isDesktopOpenAvailable,
|
||||
isLocalExecution: isLocalDaemon,
|
||||
checkoutStatus,
|
||||
}).map((target) => {
|
||||
if (target.source === "github") {
|
||||
return {
|
||||
id: target.id,
|
||||
label: target.label,
|
||||
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
||||
onOpen: () => openExternalUrl(target.url),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: target.id,
|
||||
label: target.label,
|
||||
icon: <ThemedEditorAppIcon editorId={target.id} size={16} uniProps={mutedColorMapping} />,
|
||||
onOpen: () => openDesktopTarget(target.openInput),
|
||||
};
|
||||
}),
|
||||
[
|
||||
activeFile,
|
||||
checkoutStatus,
|
||||
cwd,
|
||||
desktopOpenTargets,
|
||||
isDesktopOpenAvailable,
|
||||
isLocalDaemon,
|
||||
resolvedFile,
|
||||
],
|
||||
);
|
||||
|
||||
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
||||
@@ -216,7 +192,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
}
|
||||
}, [primaryOption, handleOpenTarget]);
|
||||
|
||||
if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) {
|
||||
if (!canResolveWorkspace || !primaryOption || targets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -229,7 +205,11 @@ export function WorkspaceOpenInEditorButton({
|
||||
onPress={handlePrimaryPress}
|
||||
disabled={openMutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
|
||||
accessibilityLabel={
|
||||
activeFileName
|
||||
? `Open ${activeFileName} in ${primaryOption.label}`
|
||||
: `Open workspace in ${primaryOption.label}`
|
||||
}
|
||||
>
|
||||
{openMutation.isPending ? (
|
||||
<ThemedActivityIndicator
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterTargetsForDaemonLocation } from "./workspace-open-targets";
|
||||
|
||||
describe("filterTargetsForDaemonLocation", () => {
|
||||
const targets = [
|
||||
{ id: "cursor", requiresLocalDaemon: true },
|
||||
{ id: "vscode", requiresLocalDaemon: true },
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
];
|
||||
|
||||
it("keeps local app targets and URL targets for the local daemon", () => {
|
||||
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets);
|
||||
});
|
||||
|
||||
it("hides local app targets for a remote daemon", () => {
|
||||
expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves target order after filtering", () => {
|
||||
expect(
|
||||
filterTargetsForDaemonLocation(
|
||||
[
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
{ id: "finder", requiresLocalDaemon: true },
|
||||
{ id: "docs", requiresLocalDaemon: false },
|
||||
],
|
||||
{ isLocalDaemon: false },
|
||||
),
|
||||
).toEqual([
|
||||
{ id: "github", requiresLocalDaemon: false },
|
||||
{ id: "docs", requiresLocalDaemon: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
export interface WorkspaceOpenTargetAvailability {
|
||||
requiresLocalDaemon: boolean;
|
||||
}
|
||||
|
||||
export function filterTargetsForDaemonLocation<Target extends WorkspaceOpenTargetAvailability>(
|
||||
targets: readonly Target[],
|
||||
input: { isLocalDaemon: boolean },
|
||||
): Target[] {
|
||||
if (input.isLocalDaemon) {
|
||||
return [...targets];
|
||||
}
|
||||
return targets.filter((target) => !target.requiresLocalDaemon);
|
||||
}
|
||||
@@ -193,6 +193,31 @@ function getWorkspaceScripts(
|
||||
return workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS;
|
||||
}
|
||||
|
||||
interface WorkspaceFileLocationFields {
|
||||
path: string | null;
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
}
|
||||
|
||||
function getWorkspaceFileLocationFields(
|
||||
tab: WorkspaceTabDescriptor | null,
|
||||
): WorkspaceFileLocationFields {
|
||||
const target = tab?.target;
|
||||
if (target?.kind !== "file") {
|
||||
return { path: null };
|
||||
}
|
||||
return { path: target.path, lineStart: target.lineStart, lineEnd: target.lineEnd };
|
||||
}
|
||||
|
||||
function buildWorkspaceFileLocation(
|
||||
fields: WorkspaceFileLocationFields,
|
||||
): WorkspaceFileLocation | null {
|
||||
if (fields.path === null) {
|
||||
return null;
|
||||
}
|
||||
return { path: fields.path, lineStart: fields.lineStart, lineEnd: fields.lineEnd };
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||
const ThemedEllipsisVertical = withUnistyles(EllipsisVertical);
|
||||
@@ -2851,6 +2876,19 @@ function WorkspaceScreenContent({
|
||||
});
|
||||
|
||||
const activeTabDescriptor = useMemo(() => activeTab?.descriptor ?? null, [activeTab]);
|
||||
const activeFileFields = getWorkspaceFileLocationFields(activeTabDescriptor);
|
||||
const activeFilePath = activeFileFields.path;
|
||||
const activeFileLineStart = activeFileFields.lineStart;
|
||||
const activeFileLineEnd = activeFileFields.lineEnd;
|
||||
const activeFileLocation = useMemo<WorkspaceFileLocation | null>(
|
||||
() =>
|
||||
buildWorkspaceFileLocation({
|
||||
path: activeFilePath,
|
||||
lineStart: activeFileLineStart,
|
||||
lineEnd: activeFileLineEnd,
|
||||
}),
|
||||
[activeFileLineEnd, activeFileLineStart, activeFilePath],
|
||||
);
|
||||
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
|
||||
const shouldRenderDesktopPaneFallback = useMemo(
|
||||
() => !isMobile && !canRenderDesktopPaneSplits,
|
||||
@@ -3083,10 +3121,11 @@ function WorkspaceScreenContent({
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile ? (
|
||||
{!isMobile && workspaceDirectory ? (
|
||||
<WorkspaceOpenInEditorButton
|
||||
serverId={normalizedServerId}
|
||||
cwd={normalizedWorkspaceId}
|
||||
cwd={workspaceDirectory}
|
||||
activeFile={activeFileLocation}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
) : null}
|
||||
@@ -3193,6 +3232,8 @@ function WorkspaceScreenContent({
|
||||
workspaceDescriptor,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
activeFileLocation,
|
||||
liveTerminalIds,
|
||||
handleScriptTerminalStarted,
|
||||
handleViewScriptTerminal,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user