From c78c30ef54d5c8e4a38f7de52a4d588224b77616 Mon Sep 17 00:00:00 2001 From: Myriad-Dreamin <35292584+Myriad-Dreamin@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:31:11 +0800 Subject: [PATCH] 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 --- .../workspace-open-in-editor-button.tsx | 159 +++++++++++------- .../app/src/utils/github-repo-url.test.ts | 51 ++++++ packages/app/src/utils/github-repo-url.ts | 53 ++++++ 3 files changed, 204 insertions(+), 59 deletions(-) create mode 100644 packages/app/src/utils/github-repo-url.test.ts create mode 100644 packages/app/src/utils/github-repo-url.ts diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index 09da0eb20..06745aff4 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -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; } 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( - () => , - [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 ? : undefined), [isPreferred], ); return ( - {editor.label} + {target.label} ); } @@ -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({ 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( + () => + availableEditors.map((editor) => ({ + id: editor.id, + label: editor.label, + icon: , + 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(() => { + if (!checkoutStatus?.isGit) { + return null; + } + const url = buildGitHubBranchTreeUrl({ + remoteUrl: checkoutStatus.remoteUrl, + branch: checkoutStatus.currentBranch, + }); + if (!url) { + return null; + } + return { + id: "github", + label: "GitHub", + icon: , + 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({ /> ) : ( - + {primaryOption.icon} {!hideLabels && Open} )} - {availableEditors.length > 1 ? ( + {targets.length > 1 ? ( - {availableEditors.map((editor: EditorTargetDescriptorPayload) => ( - ( + ))} diff --git a/packages/app/src/utils/github-repo-url.test.ts b/packages/app/src/utils/github-repo-url.test.ts new file mode 100644 index 000000000..cae70083d --- /dev/null +++ b/packages/app/src/utils/github-repo-url.test.ts @@ -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(); + }); +}); diff --git a/packages/app/src/utils/github-repo-url.ts b/packages/app/src/utils/github-repo-url.ts new file mode 100644 index 000000000..c936b535d --- /dev/null +++ b/packages/app/src/utils/github-repo-url.ts @@ -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}`; +}