History

fix(web): copy selected pull request link from PR page (#10615)

maria committed 2 months ago

Commit ea2983a1 parent d64335b5 files · +90 −33Browse files

Unified · Split

5 files
  1. apps/web/src/components/CommandPalette.tsx +19 −7
  2. apps/web/src/hooks/useOpenPanelPullRequestUrl.ts +1 −0
  3. apps/web/src/rightPanelStore.test.ts +6 −0
  4. apps/web/src/rightPanelStore.ts +34 −5
  5. apps/web/src/routes/_chat.pull-requests.tsx +30 −21
apps/web/src/components/CommandPalette.tsx+19−7
@@ -97,7 +97,11 @@ import { · 96 unmodified lines
9797 import { onOpenCommandPalette } from "../commandPaletteBus";
9898 import { isPreviewFocused } from "../lib/previewFocus";
9999 import { isTerminalFocused } from "../lib/terminalFocus";
100−import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
100+import {
101+ PULL_REQUESTS_PANEL_REF,
102+ selectActiveRightPanel,
103+ useRightPanelStore,
104+} from "../rightPanelStore";
101105 import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";
102106 import {
103107 cn,
@@ -599,17 +603,25 @@ function OpenCommandPaletteDialog(props: { · 495 unmodified lines
599603 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
600604 useHandleNewThread();
601605 const projects = useProjects();
602− const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(
603− activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null,
604− );
606+ const referenceThreadRef =
607+ pathname === "/pull-requests"
608+ ? environments.some(
609+ (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true,
610+ )
611+ ? PULL_REQUESTS_PANEL_REF
612+ : null
613+ : activeThread
614+ ? scopeThreadRef(activeThread.environmentId, activeThread.id)
615+ : null;
616+ const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef);
605617 const activeThreadReferenceCopyTarget =
606− activeThread == null
618+ referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl)
607619 ? null
608620 : resolveThreadReferenceCopyTarget({
609− threadId: activeThread.id,
621+ threadId: referenceThreadRef.threadId,
610622 openPanelPullRequestUrl,
611623 linkedPullRequestUrl:
612− activeThread.linkedPullRequest?.url ?? activeThread.branchPullRequest?.url ?? null,
624+ activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null,
613625 });
614626 const copyActiveThreadReference = useCallback(async () => {
615627 const target = activeThreadReferenceCopyTarget;
apps/web/src/hooks/useOpenPanelPullRequestUrl.ts+1−0
@@ -50,6 +50,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { · 49 unmodified lines
5050 );
5151 return reference
5252 ? (resolveDisplayedPullRequestDetail({ live: detail, cached: cachedDetail, reference })?.url ??
53+ reference.url ??
5354 gitHubPullRequestBrowserUrl(
5455 project?.repositoryIdentity,
5556 reference.repository,
apps/web/src/rightPanelStore.test.ts+6−0
@@ -587,6 +587,8 @@ describe("rightPanelStore", () => { · 586 unmodified lines
587587 const second = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4910 };
588588 useRightPanelStore.getState().openPullRequest(refA, first);
589589 useRightPanelStore.getState().openPullRequest(refA, second);
590+ const url = "https://gitlab.example.com/pingdotgg/t3code/-/merge_requests/4909";
591+ useRightPanelStore.getState().openPullRequest(refA, { ...first, url });
590592 useRightPanelStore.getState().openPullRequest(refA, first);
591593
592594 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
@@ -595,6 +597,10 @@ describe("rightPanelStore", () => { · 2 unmodified lines
595597 pullRequestSurfaceId(second),
596598 ]);
597599 expect(state.activeSurfaceId).toBe(pullRequestSurfaceId(first));
600+ expect(
601+ selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA),
602+ ).toMatchObject({ url });
603+ expect(state.surfaces[1]).not.toHaveProperty("url");
598604 });
599605
600606 it("keeps one pull request read from two servers as two tabs", () => {
apps/web/src/rightPanelStore.ts+34−5
@@ -7,8 +7,13 @@ · 6 unmodified lines
77 * terminal surfaces point at terminal session ids, file surfaces point at
88 * workspace paths, and diff/files remain singleton surfaces.
99 */
10−import { scopedThreadKey } from "@t3tools/client-runtime/environment";
11−import type { ChatFileAttachment, ScopedThreadRef } from "@t3tools/contracts";
10+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
11+import {
12+ EnvironmentId,
13+ ThreadId,
14+ type ChatFileAttachment,
15+ type ScopedThreadRef,
16+} from "@t3tools/contracts";
1217 import { create } from "zustand";
1318 import { createJSONStorage, persist } from "zustand/middleware";
1419
@@ -65,6 +70,7 @@ export type RightPanelSurface = · 50 unmodified lines
6570 projectId: string;
6671 repository: string;
6772 number: number;
73+ url?: string;
6874 }
6975 | { id: "agents"; kind: "agents" };
7076
@@ -74,8 +80,14 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; · 3 unmodified lines
7480 // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.
7581 const RIGHT_PANEL_STORAGE_VERSION = 11;
7682
83+/** A fixed workspace-level ref: each PR surface carries its own real environment. */
84+export const PULL_REQUESTS_PANEL_REF = scopeThreadRef(
85+ EnvironmentId.make("pull-requests-panel"),
86+ ThreadId.make("pull-requests-panel"),
87+);
88+
7789 /**
78− * The pull-request list's shared panel (see PULL_REQUESTS_PANEL_ID in the route) is session
90+ * The pull-request list's shared panel is session
7991 * state: reopening the app should show the list, not last session's tabs and detail fetches.
8092 */
8193 const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel");
@@ -109,7 +121,13 @@ interface RightPanelStoreState { · 27 unmodified lines
109121 openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;
110122 openPullRequest: (
111123 ref: ScopedThreadRef,
112− target: { environmentId?: string; projectId: string; repository: string; number: number },
124+ target: {
125+ environmentId?: string;
126+ projectId: string;
127+ repository: string;
128+ number: number;
129+ url?: string;
130+ },
113131 ) => void;
114132 openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;
115133 splitTerminal: (
@@ -210,6 +228,7 @@ export function pullRequestSurface(target: { · 94 unmodified lines
210228 projectId: string;
211229 repository: string;
212230 number: number;
231+ url?: string;
213232 }): PullRequestSurface {
214233 return {
215234 id: pullRequestSurfaceId(target),
@@ -218,6 +237,7 @@ export function pullRequestSurface(target: { · 2 unmodified lines
218237 projectId: target.projectId,
219238 repository: target.repository,
220239 number: target.number,
240+ ...(typeof target.url === "string" ? { url: target.url } : {}),
221241 };
222242 }
223243
@@ -444,7 +464,16 @@ export const useRightPanelStore = create<RightPanelStoreState>()( · 220 unmodified lines
444464 openPullRequest: (ref, target) =>
445465 set((state) =>
446466 userAction(state, scopedThreadKey(ref), (current) => {
447− return upsertSurface(current, pullRequestSurface(target));
467+ const surface = pullRequestSurface(target);
468+ const next = upsertSurface(current, surface);
469+ return target.url
470+ ? {
471+ ...next,
472+ surfaces: next.surfaces.map((entry) =>
473+ entry.id === surface.id ? surface : entry,
474+ ),
475+ }
476+ : next;
448477 }),
449478 ),
450479 openFile: (ref, relativePath, line) =>
apps/web/src/routes/_chat.pull-requests.tsx+30−21
@@ -1,7 +1,6 @@
11 import { RefreshIcon } from "~/components/ui/refresh-icon";
22 import { Spinner } from "~/components/ui/spinner";
3−import { scopeThreadRef } from "@t3tools/client-runtime/environment";
4−import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts";
3+import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts";
54 import type {
65 EnvironmentId,
76 ProjectId,
@@ -120,8 +119,12 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ · 112 unmodified lines
120119 import { SidebarInset } from "../components/ui/sidebar";
121120 import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
122121 import { useLiveRefresh } from "../hooks/useLiveRefresh";
122+import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
123+import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
124+import { toastManager } from "../components/ui/toast";
123125 import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
124126 import {
127+ PULL_REQUESTS_PANEL_REF,
125128 pullRequestSurfaceId,
126129 selectActiveRightPanelSurface,
127130 selectSelectedRightPanelSurface,
@@ -205,15 +208,6 @@ const PAGE_SIZE = 99; · 77 unmodified lines
205208 const MAX_PAGE_SIZE = 500;
206209 /** Stable empty map so the memos below do not see a new object on every render. */
207210 const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {};
208−/** The list owns one environment-scoped right panel rather than borrowing a real thread's. */
209−const PULL_REQUESTS_PANEL_ID = ThreadId.make("pull-requests-panel");
210−/**
211− * A fixed sentinel, not a real server: the panel is one workspace-level surface list (each
212− * surface already carries the server it was read from), so its store key must not move when a
213− * capable server disconnects or reconnects. Real environment ids are server-generated UUIDs, so
214− * this string can never collide with one.
215− */
216−const PULL_REQUESTS_PANEL_ENVIRONMENT_ID = "pull-requests-panel" as EnvironmentId;
217211 /** Stable so a read that is not wanted right now does not re-key on every render. */
218212 const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = [];
219213 const EMPTY_PREVIEW_SESSIONS = {};
@@ -424,13 +418,8 @@ function PullRequestsRouteView() { · 204 unmodified lines
424418 // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref
425419 // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab
426420 // strip survives a capable server disconnecting or losing the pull-requests capability.
427− const rightPanelRef = useMemo(
428− () =>
429− capableEnvironments.length === 0
430− ? null
431− : scopeThreadRef(PULL_REQUESTS_PANEL_ENVIRONMENT_ID, PULL_REQUESTS_PANEL_ID),
432− [capableEnvironments.length],
433− );
421+ const rightPanelRef = capableEnvironments.length === 0 ? null : PULL_REQUESTS_PANEL_REF;
422+ const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(rightPanelRef);
434423 const rightPanelState = useRightPanelStore((state) =>
435424 selectThreadRightPanelState(state.byThreadKey, rightPanelRef),
436425 );
@@ -453,7 +442,7 @@ function PullRequestsRouteView() { · 16 unmodified lines
453442 rightPanelState.isOpen && selectedPullRequestSurface !== null,
454443 rightPanelPresenceValue,
455444 panelAnimationsActive,
456− rightPanelRef === null ? null : PULL_REQUESTS_PANEL_ID,
445+ rightPanelRef?.threadId ?? null,
457446 panelAnimationDurationMs,
458447 );
459448 const rightPanelPresent = rightPanelPresence.present;
@@ -1852,8 +1841,27 @@ function PullRequestsRouteView() { · 1392 unmodified lines
18521841 selectSurfaceInUrl(null);
18531842 };
18541843
1855− // This page has no ChatView, so the shared panel handles `rightPanel.close`
1856− // itself. With nothing open the event falls through to its native meaning.
1844+ // This page has no ChatView, so it handles the shared panel shortcuts itself.
1845+ const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => {
1846+ if (!openPanelPullRequestUrl) return;
1847+ event.preventDefault();
1848+ event.stopPropagation();
1849+ if (event.repeat) return;
1850+ const url = openPanelPullRequestUrl;
1851+ void writeTextToClipboard(url, "pull request link").then(
1852+ (didCopy) => {
1853+ if (didCopy)
1854+ toastManager.add({ type: "success", title: "PR link copied", description: url });
1855+ },
1856+ (error) => {
1857+ toastManager.add({
1858+ type: "error",
1859+ title: "Failed to copy PR link",
1860+ description: error instanceof Error ? error.message : "An error occurred.",
1861+ });
1862+ },
1863+ );
1864+ });
18571865 const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => {
18581866 if (activePullRequestSurface === null) return;
18591867 event.preventDefault();
@@ -1867,6 +1875,7 @@ function PullRequestsRouteView() { · 7 unmodified lines
18671875 context: { terminalFocus: isTerminalFocused() },
18681876 });
18691877 if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event);
1878+ if (command === "thread.copyReference") copyPullRequestFromShortcut(event);
18701879 };
18711880 window.addEventListener("keydown", onKeyDown);
18721881 return () => window.removeEventListener("keydown", onKeyDown);