diff --git a/.changeset/afraid-hairs-talk.md b/.changeset/afraid-hairs-talk.md new file mode 100644 index 0000000000..21101d3bd8 --- /dev/null +++ b/.changeset/afraid-hairs-talk.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Render footer links in `app dev` as hyperlinks, if supported by the terminal. diff --git a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx index 7d6b5f5ab6..62bf6aed3a 100644 --- a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx +++ b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx @@ -14,7 +14,14 @@ import {unstyled} from '@shopify/cli-kit/node/output' import {openURL} from '@shopify/cli-kit/node/system' import {Writable} from 'stream' -vi.mock('@shopify/cli-kit/node/system') +vi.mock('@shopify/cli-kit/node/system', async () => { + const actual: any = await vi.importActual('@shopify/cli-kit/node/system') + return { + ...actual, + openURL: vi.fn(), + terminalSupportsHyperlinks: mocks.terminalSupportsHyperlinks, + } +}) vi.mock('@shopify/cli-kit/node/context/local') vi.mock('@shopify/cli-kit/node/tree-kill') @@ -23,6 +30,7 @@ const mocks = vi.hoisted(() => { useStdin: vi.fn(() => { return {isRawModeSupported: true} }), + terminalSupportsHyperlinks: vi.fn(() => false), } }) @@ -544,6 +552,59 @@ describe('DevSessionUI', () => { renderInstance.unmount() }) + test('hides URL list when terminal supports hyperlinks', async () => { + // Given + mocks.terminalSupportsHyperlinks.mockReturnValue(true) + + const renderInstance = render( + , + ) + + await waitForInputsToBeReady() + + // Then - shortcuts should be present but URL list should be hidden + const output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('(p)') + expect(output).toContain('(g)') + expect(output).not.toContain('Preview URL:') + expect(output).not.toContain('GraphiQL URL:') + + renderInstance.unmount() + mocks.terminalSupportsHyperlinks.mockReturnValue(false) + }) + + test('shows URL list when terminal does not support hyperlinks', async () => { + // Given + mocks.terminalSupportsHyperlinks.mockReturnValue(false) + + const renderInstance = render( + , + ) + + await waitForInputsToBeReady() + + // Then - both shortcuts and URL list should be present + const output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('(p)') + expect(output).toContain('(g)') + expect(output).toContain('Preview URL: https://shopify.com') + expect(output).toContain('GraphiQL URL: https://graphiql.shopify.com') + + renderInstance.unmount() + }) + test('shows non-interactive fallback when raw mode is not supported', async () => { // Given - mock useStdin to return false for isRawModeSupported mocks.useStdin.mockReturnValue({isRawModeSupported: false}) diff --git a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx index 0946bbbca5..882bb30c3b 100644 --- a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx +++ b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx @@ -15,7 +15,7 @@ import React, {FunctionComponent, useEffect, useMemo, useState} from 'react' import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort' import {Box, Text, useInput, useStdin} from '@shopify/cli-kit/node/ink' import {handleCtrlC} from '@shopify/cli-kit/node/ui' -import {openURL} from '@shopify/cli-kit/node/system' +import {openURL, terminalSupportsHyperlinks} from '@shopify/cli-kit/node/system' import figures from '@shopify/cli-kit/node/figures' import {isUnitTest} from '@shopify/cli-kit/node/context/local' import {treeKill} from '@shopify/cli-kit/node/tree-kill' @@ -126,7 +126,7 @@ const DevSessionUI: FunctionComponent = ({ shortcuts: [ { key: 'p', - condition: () => Boolean(status.previewURL && status.isReady), + condition: () => Boolean(status.isReady && status.previewURL), action: async () => { await metadata.addPublicMetadata(() => ({ cmd_dev_preview_url_opened: true, @@ -138,7 +138,7 @@ const DevSessionUI: FunctionComponent = ({ }, { key: 'g', - condition: () => Boolean(status.graphiqlURL && status.isReady), + condition: () => Boolean(status.isReady && status.graphiqlURL), action: async () => { await metadata.addPublicMetadata(() => ({ cmd_dev_graphiql_opened: true, @@ -168,19 +168,34 @@ const DevSessionUI: FunctionComponent = ({ )} {canUseShortcuts && ( - {status.isReady ? ( + {status.isReady && status.previewURL ? ( - {figures.pointerSmall} (p) Open app preview + {figures.pointerSmall} (p){' '} + {terminalSupportsHyperlinks() ? ( + + ) : ( + 'Open app preview' + )} ) : null} {status.isReady && !status.appEmbedded && status.hasExtensions ? ( - {figures.pointerSmall} (c) Open Dev Console for extension previews + {figures.pointerSmall} (c){' '} + {terminalSupportsHyperlinks() ? ( + + ) : ( + 'Open Dev Console for extension previews' + )} ) : null} - {status.graphiqlURL && status.isReady ? ( + {status.isReady && status.graphiqlURL ? ( - {figures.pointerSmall} (g) Open GraphiQL (Admin API) + {figures.pointerSmall} (g){' '} + {terminalSupportsHyperlinks() ? ( + + ) : ( + 'Open GraphiQL (Admin API)' + )} ) : null} @@ -190,7 +205,7 @@ const DevSessionUI: FunctionComponent = ({ {isShuttingDownMessage} ) : ( <> - {status.isReady && ( + {status.isReady && !(canUseShortcuts && terminalSupportsHyperlinks()) && ( <> {status.previewURL ? ( diff --git a/packages/cli-kit/src/public/node/system.ts b/packages/cli-kit/src/public/node/system.ts index 537275458e..a532dce70a 100644 --- a/packages/cli-kit/src/public/node/system.ts +++ b/packages/cli-kit/src/public/node/system.ts @@ -7,6 +7,7 @@ import {renderWarning} from './ui.js' import {platformAndArch} from './os.js' import {shouldDisplayColors, outputDebug} from './output.js' import {execa, execaCommand, ExecaChildProcess} from 'execa' +import supportsHyperlinks from 'supports-hyperlinks' import which from 'which' import {delimiter} from 'pathe' @@ -346,6 +347,15 @@ export async function sleep(seconds: number): Promise { }) } +/** + * Check if the terminal supports OSC 8 hyperlinks. + * + * @returns True if the terminal supports hyperlinks. + */ +export function terminalSupportsHyperlinks(): boolean { + return supportsHyperlinks.stdout +} + /** * Check if the standard input and output streams support prompting. *