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:
Myriad-Dreamin
2026-04-27 21:31:11 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 34626e3b8f
commit c78c30ef54
3 changed files with 204 additions and 59 deletions
@@ -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();
});
});
+53
View File
@@ -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}`;
}