Support opening workspace branches on GitHub (#583)
* Add GitHub branch open target to workspace button (#96) * refactor: simplify workspace open targets to flat shape Collapse the editor/url discriminated union into a single OpenTarget { id, label, icon, onOpen } so the button renders targets uniformly and the mutation is shape-agnostic. Drop the heavily-mocked button test in favor of the URL builder tests, restore the decodeURIComponent guard for valid-syntax-but-invalid-UTF-8 pathnames, and note the SSH host alias limitation. --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
34626e3b8f
commit
c78c30ef54
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { type ReactElement, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { EditorTargetDescriptorPayload, EditorTargetId } from "@server/shared/messages";
|
||||
import type { EditorTargetDescriptorPayload } from "@server/shared/messages";
|
||||
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -18,8 +19,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -30,38 +34,42 @@ interface WorkspaceOpenInEditorButtonProps {
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
interface EditorMenuItemProps {
|
||||
editor: EditorTargetDescriptorPayload;
|
||||
isPreferred: boolean;
|
||||
onOpen: (editorId: EditorTargetId) => void;
|
||||
interface OpenTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
onOpen: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedEditorAppIcon = withUnistyles(EditorAppIcon);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedCheckIcon = withUnistyles(Check);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function EditorMenuItem({ editor, isPreferred, onOpen }: EditorMenuItemProps) {
|
||||
const handleSelect = useCallback(() => onOpen(editor.id), [onOpen, editor.id]);
|
||||
const leading = useMemo(
|
||||
() => <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
||||
[editor.id],
|
||||
);
|
||||
interface OpenTargetMenuItemProps {
|
||||
target: OpenTarget;
|
||||
isPreferred: boolean;
|
||||
onOpen: (target: OpenTarget) => void;
|
||||
}
|
||||
|
||||
function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemProps) {
|
||||
const handleSelect = useCallback(() => onOpen(target), [onOpen, target]);
|
||||
const trailing = useMemo(
|
||||
() => (isPreferred ? <ThemedCheckIcon size={16} uniProps={mutedColorMapping} /> : undefined),
|
||||
[isPreferred],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={`workspace-open-in-editor-item-${editor.id}`}
|
||||
leading={leading}
|
||||
testID={`workspace-open-in-editor-item-${target.id}`}
|
||||
leading={target.icon}
|
||||
trailing={trailing}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{editor.label}
|
||||
{target.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -76,12 +84,12 @@ export function WorkspaceOpenInEditorButton({
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||
|
||||
const shouldLoadEditors =
|
||||
const shouldLoadTargets =
|
||||
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||
|
||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||
queryKey: ["available-editors", serverId],
|
||||
enabled: shouldLoadEditors,
|
||||
enabled: shouldLoadTargets,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
@@ -97,20 +105,65 @@ export function WorkspaceOpenInEditorButton({
|
||||
},
|
||||
});
|
||||
|
||||
const availableEditorsRaw = availableEditorsQuery.data;
|
||||
const availableEditors = useMemo(() => availableEditorsRaw ?? [], [availableEditorsRaw]);
|
||||
const availableEditorIds = useMemo(
|
||||
() => availableEditors.map((editor: EditorTargetDescriptorPayload) => editor.id),
|
||||
[availableEditors],
|
||||
const availableEditors = useMemo(
|
||||
() => availableEditorsQuery.data ?? [],
|
||||
[availableEditorsQuery.data],
|
||||
);
|
||||
|
||||
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
cwd: shouldLoadTargets ? cwd : "",
|
||||
});
|
||||
|
||||
const editorTargets = useMemo<OpenTarget[]>(
|
||||
() =>
|
||||
availableEditors.map((editor) => ({
|
||||
id: editor.id,
|
||||
label: editor.label,
|
||||
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
||||
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} />,
|
||||
onOpen: () => openExternalUrl(url),
|
||||
};
|
||||
}, [checkoutStatus]);
|
||||
|
||||
const targets = useMemo(
|
||||
() => (githubTarget ? [...editorTargets, githubTarget] : editorTargets),
|
||||
[editorTargets, githubTarget],
|
||||
);
|
||||
|
||||
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
||||
const effectivePreferredEditorId = useMemo(
|
||||
() => resolvePreferredEditorId(availableEditorIds, preferredEditorId),
|
||||
[availableEditorIds, preferredEditorId],
|
||||
() => resolvePreferredEditorId(targetIds, preferredEditorId),
|
||||
[targetIds, preferredEditorId],
|
||||
);
|
||||
const primaryOption =
|
||||
availableEditors.find(
|
||||
(editor: EditorTargetDescriptorPayload) => editor.id === effectivePreferredEditorId,
|
||||
) ?? null;
|
||||
const primaryOption = targets.find((target) => target.id === effectivePreferredEditorId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) {
|
||||
@@ -120,25 +173,16 @@ export function WorkspaceOpenInEditorButton({
|
||||
}, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]);
|
||||
|
||||
const openMutation = useMutation({
|
||||
mutationFn: async (editorId: EditorTargetId) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.openInEditor(cwd, editorId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return editorId;
|
||||
},
|
||||
mutationFn: (target: OpenTarget) => Promise.resolve(target.onOpen()),
|
||||
onError: (error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open in editor");
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open workspace");
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenEditor = useCallback(
|
||||
(editorId: EditorTargetId) => {
|
||||
void updatePreferredEditor(editorId).catch(() => undefined);
|
||||
openMutation.mutate(editorId);
|
||||
const handleOpenTarget = useCallback(
|
||||
(target: OpenTarget) => {
|
||||
void updatePreferredEditor(target.id).catch(() => undefined);
|
||||
openMutation.mutate(target);
|
||||
},
|
||||
[openMutation, updatePreferredEditor],
|
||||
);
|
||||
@@ -160,12 +204,13 @@ export function WorkspaceOpenInEditorButton({
|
||||
[],
|
||||
);
|
||||
|
||||
const primaryId = primaryOption?.id;
|
||||
const handlePrimaryPress = useCallback(() => {
|
||||
if (primaryId) handleOpenEditor(primaryId);
|
||||
}, [primaryId, handleOpenEditor]);
|
||||
if (primaryOption) {
|
||||
handleOpenTarget(primaryOption);
|
||||
}
|
||||
}, [primaryOption, handleOpenTarget]);
|
||||
|
||||
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
|
||||
if (!shouldLoadTargets || !primaryOption || targets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -188,16 +233,12 @@ export function WorkspaceOpenInEditorButton({
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.splitButtonContent}>
|
||||
<ThemedEditorAppIcon
|
||||
editorId={primaryOption.id}
|
||||
size={16}
|
||||
uniProps={mutedColorMapping}
|
||||
/>
|
||||
{primaryOption.icon}
|
||||
{!hideLabels && <Text style={styles.splitButtonText}>Open</Text>}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{availableEditors.length > 1 ? (
|
||||
{targets.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-open-in-editor-caret"
|
||||
@@ -213,12 +254,12 @@ export function WorkspaceOpenInEditorButton({
|
||||
maxWidth={176}
|
||||
testID="workspace-open-in-editor-menu"
|
||||
>
|
||||
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
|
||||
<EditorMenuItem
|
||||
key={editor.id}
|
||||
editor={editor}
|
||||
isPreferred={editor.id === effectivePreferredEditorId}
|
||||
onOpen={handleOpenEditor}
|
||||
{targets.map((target) => (
|
||||
<OpenTargetMenuItem
|
||||
key={target.id}
|
||||
target={target}
|
||||
isPreferred={target.id === effectivePreferredEditorId}
|
||||
onOpen={handleOpenTarget}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-repo-url";
|
||||
|
||||
describe("parseGitHubRepoFromRemote", () => {
|
||||
it.each([
|
||||
["https://github.com/acme/repo.git", "acme/repo"],
|
||||
["https://github.com/acme/repo", "acme/repo"],
|
||||
["git@github.com:acme/repo.git", "acme/repo"],
|
||||
["ssh://git@github.com/acme/repo.git", "acme/repo"],
|
||||
["https://github.com/acme/repo/", "acme/repo"],
|
||||
])("extracts the repo from %s", (remoteUrl, expected) => {
|
||||
expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns null for non-GitHub remotes", () => {
|
||||
expect(parseGitHubRepoFromRemote("git@gitlab.com:acme/repo.git")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid URLs", () => {
|
||||
expect(parseGitHubRepoFromRemote("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGitHubBranchTreeUrl", () => {
|
||||
it("builds a branch-specific GitHub tree URL", () => {
|
||||
expect(
|
||||
buildGitHubBranchTreeUrl({
|
||||
remoteUrl: "git@github.com:acme/repo.git",
|
||||
branch: "feature/workspace-button",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/tree/feature/workspace-button");
|
||||
});
|
||||
|
||||
it("encodes reserved branch characters while preserving slash-separated branch names", () => {
|
||||
expect(
|
||||
buildGitHubBranchTreeUrl({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "feature/ship #42",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/tree/feature/ship%20%2342");
|
||||
});
|
||||
|
||||
it("returns null when the current branch is unavailable", () => {
|
||||
expect(
|
||||
buildGitHubBranchTreeUrl({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "HEAD",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// TODO: this duplicates parseGitHubRepoFromRemote in packages/server/src/services/github-service.ts.
|
||||
// Consolidate into a shared package once we have a third caller.
|
||||
|
||||
// Note: SSH host aliases (e.g. `git@github-work:acme/repo.git` resolved via ~/.ssh/config)
|
||||
// are not detected here, so the GitHub action will silently not appear for those remotes.
|
||||
export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null {
|
||||
const trimmed = remoteUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cleaned = trimmed;
|
||||
if (cleaned.startsWith("git@github.com:")) {
|
||||
cleaned = cleaned.slice("git@github.com:".length);
|
||||
} else {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(cleaned);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hostname !== "github.com") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
cleaned = decodeURIComponent(parsed.pathname.replace(/^\/+/, ""));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
cleaned = cleaned.replace(/\/+$/, "");
|
||||
if (cleaned.endsWith(".git")) {
|
||||
cleaned = cleaned.slice(0, -".git".length);
|
||||
}
|
||||
if (!cleaned.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function buildGitHubBranchTreeUrl(input: {
|
||||
remoteUrl: string | null | undefined;
|
||||
branch: string | null | undefined;
|
||||
}): string | null {
|
||||
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
|
||||
const branch = input.branch?.trim();
|
||||
if (!repo || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
|
||||
return `https://github.com/${repo}/tree/${encodedBranch}`;
|
||||
}
|
||||
Reference in New Issue
Block a user