diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 756274a614d7..7e61c980d614 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -7,13 +7,17 @@ import * as Option from "effect/Option"; import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; -const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted( - () => ({ - buildFromTemplateMock: vi.fn(), - createFromNamedImageMock: vi.fn(), - setApplicationMenuMock: vi.fn(), - }), -); +const { + buildFromTemplateMock, + createFromBufferMock, + createFromNamedImageMock, + setApplicationMenuMock, +} = vi.hoisted(() => ({ + buildFromTemplateMock: vi.fn(), + createFromBufferMock: vi.fn(), + createFromNamedImageMock: vi.fn(), + setApplicationMenuMock: vi.fn(), +})); vi.mock("electron", () => ({ Menu: { @@ -21,6 +25,7 @@ vi.mock("electron", () => ({ setApplicationMenu: setApplicationMenuMock, }, nativeImage: { + createFromBuffer: createFromBufferMock, createFromNamedImage: createFromNamedImageMock, }, })); @@ -30,6 +35,9 @@ import * as ElectronMenu from "./ElectronMenu.ts"; const TestLayer = ElectronMenu.layer.pipe( Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), ); +const DarwinTestLayer = ElectronMenu.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), +); const makeWindow = (zoomFactor = 1): Electron.BrowserWindow => ({ @@ -40,6 +48,7 @@ const makeWindow = (zoomFactor = 1): Electron.BrowserWindow => describe("ElectronMenu", () => { beforeEach(() => { buildFromTemplateMock.mockReset(); + createFromBufferMock.mockReset(); createFromNamedImageMock.mockReset(); setApplicationMenuMock.mockReset(); }); @@ -122,6 +131,55 @@ describe("ElectronMenu", () => { }).pipe(Effect.provide(TestLayer)), ); + it.effect("appends renderer actions to the native edit menu", () => + Effect.gen(function* () { + const gitHubIcon = { + isEmpty: () => false, + setTemplateImage: vi.fn(), + }; + const nativeGitHubIcon = gitHubIcon as unknown as Electron.NativeImage; + createFromBufferMock.mockReturnValue(nativeGitHubIcon); + buildFromTemplateMock.mockImplementation(() => ({ + popup: (options: Electron.PopupOptions) => options.callback?.(), + })); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + yield* electronMenu.showContextMenu({ + window: makeWindow(), + items: [ + { + id: "copy-github-link", + label: "Copy GitHub link", + icon: "github", + accelerator: "CmdOrCtrl+Shift+C", + }, + ], + position: Option.none(), + editFlags: { + canCut: false, + canCopy: true, + canPaste: false, + canSelectAll: true, + }, + }); + + const template = buildFromTemplateMock.mock.calls[0]?.[0] as + | Electron.MenuItemConstructorOptions[] + | undefined; + assert.deepEqual( + template?.map((item) => item.type ?? item.role ?? item.label), + ["cut", "copy", "paste", "selectAll", "Copy GitHub link"], + ); + assert.deepEqual( + template?.slice(0, 4).map((item) => item.enabled), + [false, true, false, true], + ); + assert.equal(template?.at(-1)?.accelerator, "CmdOrCtrl+Shift+C"); + assert.strictEqual(template?.at(-1)?.icon, nativeGitHubIcon); + assert.deepEqual(createFromBufferMock.mock.calls[0]?.[1], { scaleFactor: 2 }); + }).pipe(Effect.provide(DarwinTestLayer)), + ); + it.effect("keeps a preceding non-destructive action in the destructive section", () => Effect.gen(function* () { buildFromTemplateMock.mockImplementation(() => ({ diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index b241619cd296..ad3123d4eed1 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -1,4 +1,4 @@ -import type { ContextMenuItem } from "@t3tools/contracts"; +import type { ContextMenuEditFlags, ContextMenuItem } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -8,6 +8,9 @@ import * as Schema from "effect/Schema"; import * as Electron from "electron"; +const GITHUB_MARK_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAGKADAAQAAAABAAAAGAAAAADB/VeXAAACD0lEQVRIDZWVP2tUQRRHX1TEgDbBFTSS1ToI6gewEGsVISqInQRsghLE2m9gZSN2/vsAKhhBthCxNgg2FqYIERWJlibqOTGz3FzX3TcXznv3ztz7m9mZebNjzXDr0n0RTsI0dED7DO/gJTyGj1BlU2Q/gnX4PQJzzLWmlZ0m6weMEs791lg71Obo/QW5uG1srRoD7QytRXwN/ye0FTa35Kvxzy/p0hiX5TbxLnCD34MDfYJFeAvLYNsHuAzjcAvKhNSagr65SaXT92y/p2l24u8PcXH34Shc7AJO1FBzww7xzKdl/m9X1fMK2XEANbvbeLgMvqNVn2uKl6IAftFuFgjiyK9SYk34NGk9dxS/0GjPYlDpO0C0aQfYG1vwv6S4JvyakjsO4JmPNhGDSj/XrjlAnvHxStGYfiwGRTtvjB9JJyW2CfeQ5C0bD8wTf0EPou0muAvbY+MIf4z+O5D3s2fdJJR75DX+EjiLN3AKnMT/TOET0IM4c301D8CGPeBpo8vlLF5sxrZ9gxuQ7SoNnposXOL7scBf8X0z+R5v98CLzWQ/+aOQ7TANRSy/1erPvhSex/GqVfAIuAfxb5Jwi7k85mZxNWa2ZIbgGr4JK3ATvNcvgWKDLA9g7fVBibHN0VchzmxHTAi+giXPZfHKbmWun5vkSVDA/4RB5gDmPAT3sdosOjek6ix9B4f0N38AdIrKZCnytp4AAAAASUVORK5CYII="; + export interface ElectronMenuPosition { readonly x: number; readonly y: number; @@ -17,6 +20,7 @@ export interface ElectronMenuContextInput { readonly window: Electron.BrowserWindow; readonly items: readonly ContextMenuItem[]; readonly position: Option.Option; + readonly editFlags?: ContextMenuEditFlags; } export interface ElectronMenuTemplateInput { @@ -79,6 +83,8 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), + ...(sourceItem.icon === undefined ? {} : { icon: sourceItem.icon }), + ...(sourceItem.accelerator === undefined ? {} : { accelerator: sourceItem.accelerator }), }; if (sourceItem.children) { @@ -95,6 +101,17 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM return normalizedItems; } +export function buildEditContextMenuTemplate( + flags: ContextMenuEditFlags, +): Electron.MenuItemConstructorOptions[] { + return [ + { role: "cut", enabled: flags.canCut }, + { role: "copy", enabled: flags.canCopy }, + { role: "paste", enabled: flags.canPaste }, + { role: "selectAll", enabled: flags.canSelectAll }, + ]; +} + // Renderer positions arrive in CSS pixels; popup() expects window points, so // page zoom must be factored in or menus drift proportionally to their // distance from the window origin. @@ -113,6 +130,7 @@ const normalizePosition = ( export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; let destructiveMenuIconCache: Option.Option | undefined; + let gitHubMenuIconCache: Option.Option | undefined; const getDestructiveMenuIcon = (): Option.Option => { if (platform !== "darwin") { @@ -136,6 +154,24 @@ export const make = Effect.gen(function* () { return destructiveMenuIconCache; }; + const getGitHubMenuIcon = (): Option.Option => { + if (platform !== "darwin") return Option.none(); + if (gitHubMenuIconCache !== undefined) return gitHubMenuIconCache; + + try { + const icon = Electron.nativeImage.createFromBuffer( + Buffer.from(GITHUB_MARK_PNG_BASE64, "base64"), + { scaleFactor: 2 }, + ); + icon.setTemplateImage(true); + gitHubMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + gitHubMenuIconCache = Option.none(); + } + + return gitHubMenuIconCache; + }; + const buildTemplate = ( entries: readonly ContextMenuItem[], complete: (selectedItemId: Option.Option) => void, @@ -166,12 +202,17 @@ export const make = Effect.gen(function* () { const itemOption: Electron.MenuItemConstructorOptions = { label: item.label, enabled: !item.disabled, + ...(item.accelerator === undefined ? {} : { accelerator: item.accelerator }), }; if (item.children && item.children.length > 0) { itemOption.submenu = buildTemplate(item.children, complete); } else { itemOption.click = () => complete(Option.some(item.id)); } + if (item.icon === "github") { + const gitHubIcon = getGitHubMenuIcon(); + if (Option.isSome(gitHubIcon)) itemOption.icon = gitHubIcon.value; + } if (item.destructive && (!item.children || item.children.length === 0)) { const destructiveIcon = getDestructiveMenuIcon(); if (Option.isSome(destructiveIcon)) { @@ -220,7 +261,7 @@ export const make = Effect.gen(function* () { showContextMenu: (input) => Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); - if (normalizedItems.length === 0) { + if (normalizedItems.length === 0 && input.editFlags === undefined) { resume(Effect.succeed(Option.none())); return; } @@ -235,7 +276,10 @@ export const make = Effect.gen(function* () { }; try { - const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const template = + input.editFlags === undefined ? [] : buildEditContextMenuTemplate(input.editFlags); + template.push(...buildTemplate(normalizedItems, complete)); + const menu = Electron.Menu.buildFromTemplate(template); const popupPosition = normalizePosition( input.position, input.window.webContents.getZoomFactor(), diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index edae8394302c..9c5292fec835 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -1,4 +1,5 @@ import { + ContextMenuEditFlagsSchema, ContextMenuItemSchema, DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, @@ -49,6 +50,7 @@ const ContextMenuPosition = Schema.Struct({ const ContextMenuInput = Schema.Struct({ items: Schema.Array(ContextMenuItemSchema), position: Schema.optionalKey(ContextMenuPosition), + editFlags: Schema.optionalKey(ContextMenuEditFlagsSchema), }); function toWebSocketBaseUrl(httpBaseUrl: URL): string { @@ -283,6 +285,7 @@ export const showContextMenu = DesktopIpc.makeIpcMethod({ window: window.value, items: input.items, position: Option.fromNullishOr(input.position), + ...(input.editFlags === undefined ? {} : { editFlags: input.editFlags }), }); return Option.getOrNull(selectedItemId); }), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 685a9b1204db..ab0c4b45110e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -110,10 +110,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), - showContextMenu: (items, position) => + showContextMenu: (items, position, editFlags) => ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { items, ...(position === undefined ? {} : { position }), + ...(editFlags === undefined ? {} : { editFlags }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index d87c74428a99..6f8ddbdea292 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -530,12 +530,7 @@ export const make = Effect.gen(function* () { menuTemplate.push({ type: "separator" }); } - menuTemplate.push( - { role: "cut", enabled: params.editFlags.canCut }, - { role: "copy", enabled: params.editFlags.canCopy }, - { role: "paste", enabled: params.editFlags.canPaste }, - { role: "selectAll", enabled: params.editFlags.canSelectAll }, - ); + menuTemplate.push(...ElectronMenu.buildEditContextMenuTemplate(params.editFlags)); void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate })); }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 72232a78b689..4a1f5f1b00a1 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -119,6 +119,30 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(resolverLayer)); }); + it.effect("recognizes fetch remotes annotated with a partial clone filter", () => { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-partial-clone-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "https://github.com/T3Tools/t3code.git"]); + yield* git(cwd, ["config", "remote.origin.promisor", "true"]); + yield* git(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + const remotes = yield* git(cwd, ["remote", "-v"]); + + expect(remotes.stdout).toContain("(fetch) [blob:none]"); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.provider).toBe("github"); + expect(identity?.locator.remoteName).toBe("origin"); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)); + }); + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index bf3c570c3cac..beae539ff01f 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -34,7 +34,7 @@ function parseRemoteFetchUrls(stdout: string): Map { for (const line of stdout.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) continue; - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\r\n]+\])?$/.exec(trimmed); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8e76413496b7..7b1e0d3c95ed 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1273,6 +1273,54 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("refName operations", () => { + it.effect("filters exact ref names before pagination", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + + const targetBranch = "feature/github-links"; + const newerSimilarBranch = `${targetBranch}-next`; + yield* git(cwd, ["branch", targetBranch]); + yield* git(cwd, ["checkout", "-b", newerSimilarBranch]); + yield* writeTextFile(cwd, "newer.txt", "newer\n"); + yield* git(cwd, ["add", "newer.txt"]); + yield* git(cwd, ["commit", "-m", "newer similar branch"], { + GIT_AUTHOR_DATE: "2030-01-02T00:00:00Z", + GIT_COMMITTER_DATE: "2030-01-02T00:00:00Z", + }); + yield* git(cwd, ["push", "origin", initialBranch, targetBranch, newerSimilarBranch]); + + const driver = yield* GitVcsDriver.GitVcsDriver; + const exactRefName = `origin/${targetBranch}`; + const fuzzy = yield* driver.listRefs({ + cwd, + query: exactRefName, + includeMatchingRemoteRefs: true, + refKind: "remote", + limit: 1, + refresh: true, + }); + assert.equal(fuzzy.refs[0]?.name, `origin/${newerSimilarBranch}`); + + const exact = yield* driver.listRefs({ + cwd, + exactName: exactRefName, + includeMatchingRemoteRefs: true, + refKind: "remote", + limit: 1, + }); + assert.deepEqual( + exact.refs.map((ref) => ref.name), + [exactRefName], + ); + assert.equal(exact.totalCount, 1); + assert.equal(exact.nextCursor, null); + }), + ); + it.effect("optionally includes remote refs that match local branches", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3a4a172a6436..dfaef53bfeb4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2814,8 +2814,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : input.refKind === "remote" ? allBranches.filter((ref) => ref.isRemote) : allBranches; + const branchesMatchingExactName = + input.exactName === undefined + ? branchesForKind + : branchesForKind.filter((ref) => ref.name === input.exactName); const refs = paginateBranches({ - refs: filterBranchesForListQuery(branchesForKind, input.query), + refs: filterBranchesForListQuery(branchesMatchingExactName, input.query), cursor: input.cursor, limit: input.limit, }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4ba53f47b6a4..83f71c6399f3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7564,6 +7564,13 @@ function ChatViewContent(props: ChatViewProps) { environmentId={activeThread.environmentId} cwd={activeWorkspaceRoot ?? ""} projectName={activeProject?.title ?? ""} + repositoryIdentity={activeProject?.repositoryIdentity ?? null} + repositoryRoot={ + activeThreadWorktreePath === null + ? activeProject?.repositoryIdentity?.rootPath + : undefined + } + gitRef={gitStatusQuery.data?.refName ?? null} threadRef={activeThreadRef} composerDraftTarget={composerDraftTarget} keybindings={keybindings} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 0ad18434d7e7..ab7a815bab67 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -2,6 +2,7 @@ import type { ChatFileAttachment, EditorId, EnvironmentId, + RepositoryIdentity, ResolvedKeybindingsConfig, ScopedThreadRef, } from "@t3tools/contracts"; @@ -19,7 +20,7 @@ import { import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; import * as Schema from "effect/Schema"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; @@ -31,10 +32,13 @@ import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; +import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; +import { isElectron } from "~/env"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links"; import { ScrollArea } from "~/components/ui/scroll-area"; @@ -47,8 +51,10 @@ import { assetEnvironment } from "~/state/assets"; import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { projectEnvironment } from "~/state/projects"; +import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; +import { vcsEnvironment } from "~/state/vcs"; import FileBrowserPanel from "./FileBrowserPanel"; import { FileBreadcrumbs } from "./FileBreadcrumbs"; @@ -63,6 +69,11 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { installFileEditorDismissal } from "./fileEditorDismissal"; +import { + FILE_LINE_CONTEXT_MENU_ITEMS, + buildGitHubFileLineUrl, + fileLineNumberFromComposedPath, +} from "./fileGitHubLink"; import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; @@ -83,6 +94,9 @@ interface FilePreviewPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; + repositoryIdentity: RepositoryIdentity | null; + repositoryRoot: string | undefined; + gitRef: string | null; relativePath: string | null; attachment?: ChatFileAttachment; threadRef: ScopedThreadRef; @@ -983,6 +997,9 @@ export default function FilePreviewPanel({ environmentId, cwd, projectName, + repositoryIdentity, + repositoryRoot, + gitRef, relativePath, attachment, threadRef, @@ -1022,6 +1039,29 @@ export default function FilePreviewPanel({ relativePath, attachment === undefined && !isMedia && !isPdf, ); + const sourceEditable = file.data !== null && !file.data.truncated && !isHostFile; + const shouldResolvePublishedRef = + isElectron && + attachment === undefined && + relativePath !== null && + !isMedia && + !isPdf && + repositoryIdentity?.provider === "github" && + gitRef !== null; + const publishedRefQuery = useEnvironmentQuery( + shouldResolvePublishedRef && repositoryIdentity !== null && gitRef !== null + ? vcsEnvironment.listRefs({ + environmentId, + input: { + cwd, + exactName: `${repositoryIdentity.locator.remoteName}/${gitRef}`, + includeMatchingRemoteRefs: true, + refKind: "remote", + limit: 1, + }, + }) + : null, + ); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); const showExplorer = shouldShowFileExplorer({ relativePath, @@ -1079,6 +1119,12 @@ export default function FilePreviewPanel({ refresh: file.refresh, resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, }); + useWorkspaceMutationRefresh({ + enabled: shouldResolvePublishedRef, + mutationId: workspaceMutationId, + refresh: publishedRefQuery.refresh, + resourceKey: `published-ref:${environmentId}:${cwd}:${repositoryIdentity?.locator.remoteName ?? ""}:${gitRef ?? ""}`, + }); useEffect(() => { const currentCrumb = breadcrumbRef.current?.querySelector( @@ -1124,6 +1170,63 @@ export default function FilePreviewPanel({ })(); }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]); + const handleSourceLineContextMenu = useCallback( + (event: MouseEvent) => { + if (!isElectron || attachment !== undefined || relativePath === null) return; + const line = fileLineNumberFromComposedPath(event.nativeEvent.composedPath()); + if (line === null) return; + const url = buildGitHubFileLineUrl({ + identity: repositoryIdentity, + refName: gitRef, + relativePath, + workspaceRoot: cwd, + repositoryRoot, + remoteRefs: publishedRefQuery.data?.refs, + line, + }); + if (url === null) return; + + const api = readLocalApi(); + if (!api) return; + event.preventDefault(); + const position = { x: event.clientX, y: event.clientY }; + const selection = event.currentTarget.ownerDocument.getSelection(); + void (async () => { + try { + const action = await api.contextMenu.show(FILE_LINE_CONTEXT_MENU_ITEMS, position, { + canCut: sourceEditable && selection?.isCollapsed === false, + canCopy: selection?.isCollapsed === false, + canPaste: sourceEditable, + canSelectAll: true, + }); + if (action !== "copy-github-link") return; + await writeTextToClipboard(url, "GitHub link"); + toastManager.add({ + type: "success", + title: "GitHub link copied", + description: `${relativePath}#L${line}`, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not copy GitHub link", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + })(); + }, + [ + attachment, + cwd, + gitRef, + publishedRefQuery.data?.refs, + relativePath, + repositoryIdentity, + repositoryRoot, + sourceEditable, + ], + ); + return (
{relativePath ? ( @@ -1251,6 +1354,7 @@ export default function FilePreviewPanel({ "min-w-0 flex-1 flex-col overflow-hidden", relativePath ? "flex" : "hidden", )} + onContextMenu={handleSourceLineContextMenu} > {relativePath && attachment ? ( diff --git a/apps/web/src/components/files/fileGitHubLink.test.ts b/apps/web/src/components/files/fileGitHubLink.test.ts new file mode 100644 index 000000000000..8f9921aeb42b --- /dev/null +++ b/apps/web/src/components/files/fileGitHubLink.test.ts @@ -0,0 +1,153 @@ +import type { RepositoryIdentity } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { buildGitHubFileLineUrl, fileLineNumberFromComposedPath } from "./fileGitHubLink"; + +const identity = (overrides: Partial = {}): RepositoryIdentity => ({ + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:T3Tools/T3Code.git", + }, + rootPath: "/repo", + displayName: "t3tools/t3code", + provider: "github", + owner: "t3tools", + name: "t3code", + ...overrides, +}); + +class TestElement extends EventTarget { + constructor(private readonly attributes: Record) { + super(); + } + + getAttribute(name: string): string | null { + return this.attributes[name] ?? null; + } +} + +const element = (attributes: Record) => new TestElement(attributes); +const remoteRef = (refName: string, remoteName = "origin") => ({ + name: `${remoteName}/${refName}`, + isRemote: true, + remoteName, +}); + +beforeEach(() => vi.stubGlobal("Element", TestElement)); +afterEach(() => vi.unstubAllGlobals()); + +describe("file GitHub line links", () => { + it("finds source lines and gutter line numbers across a composed event path", () => { + expect(fileLineNumberFromComposedPath([element({}), element({ "data-line": "42" })])).toBe(42); + expect( + fileLineNumberFromComposedPath([element({ "data-column-number": "17" }), element({})]), + ).toBe(17); + }); + + it("ignores context-menu targets that are not valid source lines", () => { + expect( + fileLineNumberFromComposedPath([ + new EventTarget(), + element({ "data-line-annotation": "1,2" }), + element({ "data-line": "0" }), + element({ "data-column-number": "12px" }), + ]), + ).toBeNull(); + }); + + it("builds a link to the checked-out GitHub branch and line", () => { + expect( + buildGitHubFileLineUrl({ + identity: identity(), + refName: "feature/github-links", + relativePath: "apps/web/src/main.ts", + workspaceRoot: "/repo", + repositoryRoot: "/repo", + remoteRefs: [remoteRef("feature/github-links")], + line: 42, + }), + ).toBe("https://github.com/t3tools/t3code/blob/feature/github-links/apps/web/src/main.ts#L42"); + }); + + it("includes a nested workspace path and escapes file names", () => { + expect( + buildGitHubFileLineUrl({ + identity: identity(), + refName: "main", + relativePath: "src/100% #ready.ts", + workspaceRoot: "/repo/apps/web", + repositoryRoot: "/repo", + remoteRefs: [remoteRef("main")], + line: 7, + }), + ).toBe("https://github.com/t3tools/t3code/blob/main/apps/web/src/100%25%20%23ready.ts#L7"); + }); + + it("preserves repository path casing for a nested Windows workspace", () => { + expect( + buildGitHubFileLineUrl({ + identity: identity(), + refName: "main", + relativePath: "src\\Main.ts", + workspaceRoot: "C:\\Repo\\Apps\\Web", + repositoryRoot: "c:\\repo", + remoteRefs: [remoteRef("main")], + line: 9, + }), + ).toBe("https://github.com/t3tools/t3code/blob/main/Apps/Web/src/Main.ts#L9"); + }); + + it("uses an enterprise GitHub hostname for SSH remotes", () => { + expect( + buildGitHubFileLineUrl({ + identity: identity({ + canonicalKey: "github.acme.test/platform/product", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.acme.test:platform/product.git", + }, + displayName: "platform/product", + owner: "platform", + name: "product", + }), + refName: "main", + relativePath: "README.md", + workspaceRoot: "/worktrees/product-task", + remoteRefs: [remoteRef("main")], + line: 1, + }), + ).toBe("https://github.acme.test/platform/product/blob/main/README.md#L1"); + }); + + it("does not invent links without a GitHub repository, branch, or workspace file", () => { + const validInput = { + identity: identity(), + refName: "main", + relativePath: "src/main.ts", + workspaceRoot: "/repo", + repositoryRoot: "/repo", + remoteRefs: [remoteRef("main")], + line: 1, + } as const; + + expect(buildGitHubFileLineUrl({ ...validInput, identity: null })).toBeNull(); + expect( + buildGitHubFileLineUrl({ + ...validInput, + identity: identity({ provider: "gitlab" }), + }), + ).toBeNull(); + expect(buildGitHubFileLineUrl({ ...validInput, refName: null })).toBeNull(); + expect(buildGitHubFileLineUrl({ ...validInput, relativePath: "/tmp/main.ts" })).toBeNull(); + expect(buildGitHubFileLineUrl({ ...validInput, relativePath: "../main.ts" })).toBeNull(); + expect(buildGitHubFileLineUrl({ ...validInput, workspaceRoot: "/other/project" })).toBeNull(); + expect(buildGitHubFileLineUrl({ ...validInput, remoteRefs: [] })).toBeNull(); + expect( + buildGitHubFileLineUrl({ ...validInput, remoteRefs: [remoteRef("main", "fork")] }), + ).toBeNull(); + expect(buildGitHubFileLineUrl({ ...validInput, line: 0 })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/files/fileGitHubLink.ts b/apps/web/src/components/files/fileGitHubLink.ts new file mode 100644 index 000000000000..31eaea41d076 --- /dev/null +++ b/apps/web/src/components/files/fileGitHubLink.ts @@ -0,0 +1,157 @@ +import type { ContextMenuItem, RepositoryIdentity, VcsRef } from "@t3tools/contracts"; +import { + isWindowsAbsolutePath, + normalizeProjectPathForComparison, + normalizeProjectPathForDispatch, +} from "@t3tools/shared/path"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; + +export type FileLineContextMenuAction = "copy-github-link"; +type GitHubRemoteRef = Pick; + +export const FILE_LINE_CONTEXT_MENU_ITEMS = [ + { + id: "copy-github-link", + label: "Copy GitHub link", + icon: "github", + accelerator: "CmdOrCtrl+Shift+C", + }, +] as const satisfies readonly ContextMenuItem[]; + +function parseLineNumber(value: string | null): number | null { + if (value === null || !/^[1-9]\d*$/u.test(value)) return null; + const line = Number(value); + return Number.isSafeInteger(line) ? line : null; +} + +/** Context-menu events are composed, so their path retains Pierre's shadow-DOM line elements. */ +export function fileLineNumberFromComposedPath(path: ReadonlyArray): number | null { + for (const target of path) { + if (!(target instanceof Element)) continue; + const line = + parseLineNumber(target.getAttribute("data-line")) ?? + parseLineNumber(target.getAttribute("data-column-number")); + if (line !== null) return line; + } + return null; +} + +function relativePathSegments(value: string): ReadonlyArray | null { + if (value.startsWith("/") || isWindowsAbsolutePath(value) || /^[a-zA-Z]:/u.test(value)) { + return null; + } + const segments = value + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0 && segment !== "."); + if (segments.length === 0 || segments.includes("..")) return null; + return segments; +} + +function workspacePathSegments( + workspaceRoot: string, + repositoryRoot: string | undefined, +): ReadonlyArray | null { + if (!repositoryRoot) return []; + + const workspacePath = normalizeProjectPathForDispatch(workspaceRoot).replaceAll("\\", "/"); + const repositoryPath = normalizeProjectPathForDispatch(repositoryRoot).replaceAll("\\", "/"); + const comparableWorkspacePath = normalizeProjectPathForComparison(workspaceRoot).replaceAll( + "\\", + "/", + ); + const comparableRepositoryPath = normalizeProjectPathForComparison(repositoryRoot).replaceAll( + "\\", + "/", + ); + if (comparableWorkspacePath === comparableRepositoryPath) return []; + + const repositoryPrefix = repositoryPath.endsWith("/") ? repositoryPath : `${repositoryPath}/`; + const comparableRepositoryPrefix = comparableRepositoryPath.endsWith("/") + ? comparableRepositoryPath + : `${comparableRepositoryPath}/`; + if (!comparableWorkspacePath.startsWith(comparableRepositoryPrefix)) return null; + + return workspacePath.slice(repositoryPrefix.length).split("/").filter(Boolean); +} + +function repositoryCoordinates( + identity: RepositoryIdentity, +): { readonly owner: string; readonly name: string } | null { + const owner = identity.owner?.trim(); + const name = identity.name?.trim(); + if (!owner || !name) return null; + return { owner, name }; +} + +function repositoryOrigin(identity: RepositoryIdentity): URL | null { + const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl); + if (provider?.kind !== "github") return null; + try { + return new URL(provider.baseUrl); + } catch { + return null; + } +} + +function isGitHubRefPublished( + identity: RepositoryIdentity | null | undefined, + refName: string | null | undefined, + refs: ReadonlyArray | undefined, +): boolean { + const normalizedRefName = refName?.trim(); + if (identity?.provider !== "github" || !normalizedRefName) return false; + + const remoteName = identity.locator.remoteName; + return ( + refs?.some( + (ref) => + ref.isRemote === true && + ref.remoteName === remoteName && + ref.name === `${remoteName}/${normalizedRefName}`, + ) === true + ); +} + +export function buildGitHubFileLineUrl(input: { + readonly identity: RepositoryIdentity | null | undefined; + readonly refName: string | null | undefined; + readonly relativePath: string; + readonly workspaceRoot: string; + /** Omit for a separate worktree, whose path is unrelated to the project's original Git root. */ + readonly repositoryRoot?: string | undefined; + readonly remoteRefs: ReadonlyArray | undefined; + readonly line: number; +}): string | null { + const identity = input.identity; + const refName = input.refName?.trim(); + if ( + identity?.provider !== "github" || + !refName || + !isGitHubRefPublished(identity, refName, input.remoteRefs) || + !Number.isSafeInteger(input.line) || + input.line < 1 + ) { + return null; + } + + const repository = repositoryCoordinates(identity); + const origin = repositoryOrigin(identity); + const fileSegments = relativePathSegments(input.relativePath); + const workspaceSegments = workspacePathSegments(input.workspaceRoot, input.repositoryRoot); + if (!repository || !origin || !fileSegments || !workspaceSegments) return null; + + const pathSegments = [ + repository.owner, + repository.name, + "blob", + ...refName.split("/"), + ...workspaceSegments, + ...fileSegments, + ]; + if (pathSegments.some((segment) => segment.length === 0)) return null; + + origin.pathname = `/${pathSegments.map(encodeURIComponent).join("/")}`; + origin.hash = `L${input.line}`; + return origin.toString(); +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 9220252cb20e..cc3a924590d2 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -128,15 +128,21 @@ describe("LocalApi", () => { const { createLocalApi } = await import("./localApi"); const api = createLocalApi(); const items = [{ id: "delete", label: "Delete" }] as const; - - await expect(api.contextMenu.show(items)).resolves.toBe("delete"); + const editFlags = { + canCut: false, + canCopy: true, + canPaste: false, + canSelectAll: true, + } as const; + + await expect(api.contextMenu.show(items, undefined, editFlags)).resolves.toBe("delete"); requestConfirmDialogMock.mockReturnValue(undefined); await expect(api.dialogs.confirm("Install update?")).resolves.toBe(false); await expect(api.dialogs.pickFolder({ initialPath: "/tmp" })).resolves.toBe("/tmp/project"); await expect(api.persistence.getClientSettings()).resolves.toEqual(DEFAULT_CLIENT_SETTINGS); await api.persistence.setClientSettings(DEFAULT_CLIENT_SETTINGS); - expect(showContextMenu).toHaveBeenCalledWith(items, undefined); + expect(showContextMenu).toHaveBeenCalledWith(items, undefined, editFlags); expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp" }); expect(getClientSettings).toHaveBeenCalledTimes(1); expect(setClientSettings).toHaveBeenCalledWith(DEFAULT_CLIENT_SETTINGS); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 8f55f65e40de..3bcb2f797b49 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -34,9 +34,14 @@ function createBrowserLocalApi(): LocalApi { show: async ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + editFlags?: Parameters[2], ): Promise => { if (window.desktopBridge) { - return window.desktopBridge.showContextMenu(items, position) as Promise; + return window.desktopBridge.showContextMenu( + items, + position, + editFlags, + ) as Promise; } return showContextMenuFallback(items, position); }, diff --git a/docs/user/composer.md b/docs/user/composer.md index 1affd1631ca2..9ae8434a8b7e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -145,6 +145,12 @@ have a cached copy. Supported video formats and codecs depend on the browser or Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `