Open workspace files in desktop targets
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user