Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/clickable-options-and-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@shopify/app': minor
'@shopify/cli': minor
'@shopify/cli-kit': minor
---

Enable mouse support for prompts and app dev tabs; hold Option in iTerm2 or Shift elsewhere to select text, or run `shopify config mouse off` to disable it.
20 changes: 20 additions & 0 deletions docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -3158,6 +3158,26 @@
"value": "export interface configautoupgradestatus {\n\n}"
}
},
"configmouseoff": {
"docs-shopify.dev/commands/interfaces/config-mouse-off.interface.ts": {
"filePath": "docs-shopify.dev/commands/interfaces/config-mouse-off.interface.ts",
"name": "configmouseoff",
"description": "The following flags are available for the `config mouse off` command:",
"isPublicDocs": true,
"members": [],
"value": "export interface configmouseoff {\n\n}"
}
},
"configmouseon": {
"docs-shopify.dev/commands/interfaces/config-mouse-on.interface.ts": {
"filePath": "docs-shopify.dev/commands/interfaces/config-mouse-on.interface.ts",
"name": "configmouseon",
"description": "The following flags are available for the `config mouse on` command:",
"isPublicDocs": true,
"members": [],
"value": "export interface configmouseon {\n\n}"
}
},
"docfetch": {
"docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts": {
"filePath": "docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ const initialStatus: DevSessionStatus = {

const onAbort = vi.fn()

function mouseWheelUp(column: number, row: number): string {
return `\u001B[<64;${column};${row}M`
}

describe('DevSessionUI', () => {
beforeEach(() => {
mocks.terminalSupportsHyperlinks.mockReturnValue(false)
Expand Down Expand Up @@ -383,6 +387,7 @@ describe('DevSessionUI', () => {
onAbort={onAbort}
/>,
)
await waitForContent(renderInstance, 'third backend message')

const promise = renderInstance.waitUntilExit()

Expand Down Expand Up @@ -561,6 +566,29 @@ describe('DevSessionUI', () => {
renderInstance.unmount()
})

test('temporarily releases mouse reporting when scrolling', async () => {
const renderInstance = render(
<DevSessionUI
processes={[]}
abortController={new AbortController()}
devSessionStatusManager={devSessionStatusManager}
shopFqdn="mystore.myshopify.com"
onAbort={onAbort}
/>,
{stdoutIsTTY: true},
)
const stdoutWrite = vi.spyOn(renderInstance.stdout, 'write')

await waitForInputsToBeReady()
// The row is intentionally outside the rendered UI to cover trackpad gestures
// over blank areas of the terminal viewport.
await sendInputAndWait(renderInstance, 10, mouseWheelUp(2, 200))

expect(stdoutWrite).toHaveBeenCalledWith('\u001B[?1003l\u001B[?1002l\u001B[?1000l')

renderInstance.unmount()
})

test('hides Local URL in app info when an app URL is available', async () => {
// Given
const renderInstance = render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {Alert, ConcurrentOutput, Link, TabularData} from '@shopify/cli-kit/node/
import {useAbortSignal} from '@shopify/cli-kit/node/ui/hooks'
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 {Box, MouseProvider, Text, useInput, useStdin} from '@shopify/cli-kit/node/ink'
import {handleCtrlC} from '@shopify/cli-kit/node/ui'
import {openURL, terminalSupportsHyperlinks} from '@shopify/cli-kit/node/system'
import figures from '@shopify/cli-kit/node/figures'
Expand Down Expand Up @@ -250,7 +250,7 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
},
}

return (
const content = (
<>
<ConcurrentOutput
processes={errorHandledProcesses}
Expand Down Expand Up @@ -302,6 +302,14 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
) : null}
</>
)

return canUseShortcuts && !isAborted ? (
<MouseProvider allowTerminalScrolling trackMouseMovement={false}>
{content}
</MouseProvider>
) : (
content
)
}

export {DevSessionUI}
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import {TabPanel, Tab} from './TabPanel.js'
import {
render,
render as renderUI,
sendInputAndWait,
sendInputAndWaitForChange,
waitForContent,
waitForInputsToBeReady,
} from '@shopify/cli-kit/node/testing/ui'
import React from 'react'
import {describe, expect, test, vi} from 'vitest'
import {unstyled} from '@shopify/cli-kit/node/output'
import {Text} from '@shopify/cli-kit/node/ink'
import {MouseProvider, Text} from '@shopify/cli-kit/node/ink'

const render = (element: React.ReactElement) => renderUI(<MouseProvider>{element}</MouseProvider>, {stdoutIsTTY: true})

function mouseClick(column: number, row: number): [string, string] {
return [`\u001B[<0;${column};${row}M`, `\u001B[<0;${column};${row}m`]
}

const mocks = vi.hoisted(() => {
return {
Expand Down Expand Up @@ -109,6 +116,34 @@ describe('TabPanel', () => {
renderInstance.unmount()
})

test('switches to a different tab when its header is clicked', async () => {
const renderInstance = render(<TabPanel tabs={sampleTabs} initialActiveTab="a" />)

await waitForInputsToBeReady()
await waitForContent(renderInstance, 'Second tab content', () =>
mouseClick(20, 2).forEach((input) => renderInstance.stdin.write(input)),
)

expect(renderInstance.lastFrame()).toContain('Second tab content')
expect(renderInstance.lastFrame()).not.toContain('First tab content')

renderInstance.unmount()
})

test('accounts for output rendered before the tab panel', async () => {
const renderInstance = render(<TabPanel tabs={sampleTabs} initialActiveTab="a" />)

await waitForInputsToBeReady()
await sendInputAndWait(renderInstance, 60, '\u001B[40;1R')
await waitForContent(renderInstance, 'Second tab content', () =>
mouseClick(20, 37).forEach((input) => renderInstance.stdin.write(input)),
)

expect(renderInstance.lastFrame()).toContain('Second tab content')

renderInstance.unmount()
})

test('executes tab action when action tab is pressed', async () => {
const renderInstance = render(<TabPanel tabs={sampleTabs} initialActiveTab="a" />)

Expand Down
77 changes: 59 additions & 18 deletions packages/app/src/cli/services/dev/ui/components/TabPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import React, {useState, useRef, useLayoutEffect} from 'react'
import {Box, Text, useInput, useStdin, useStdout, measureElement} from '@shopify/cli-kit/node/ink'
import {
Box,
Text,
useInput,
useStdin,
useStdout,
measureElement,
useOnClick,
type DOMElement,
} from '@shopify/cli-kit/node/ink'

export interface Tab {
label: string
Expand Down Expand Up @@ -27,6 +36,27 @@ interface TabPanelProps {
// Using a width less than 100% reduces (but doesn't eliminate) screen artifacts when resizing the terminal
const TAB_WIDTH_PERCENTAGE = 0.9

interface ClickableTabProps {
active?: boolean
header: string
onClick: () => void
}

const ClickableTab: React.FunctionComponent<ClickableTabProps> = ({active = false, header, onClick}) => {
const tabRef = useRef<DOMElement>(null)
useOnClick(tabRef, (event) => {
if (event.button === 'left') onClick()
})

return (
<Box ref={tabRef}>
<Text bold={active} inverse={active} wrap="truncate">
{header}
</Text>
</Box>
)
}

export const TabPanel: React.FunctionComponent<TabPanelProps> = ({tabs, initialActiveTab}) => {
const {stdout} = useStdout()
const {isRawModeSupported: canUseShortcuts} = useStdin()
Expand Down Expand Up @@ -112,6 +142,19 @@ export const TabPanel: React.FunctionComponent<TabPanelProps> = ({tabs, initialA
const contentTabs = tabsArray.filter((tab) => !tab.action)
const actionTabs = tabsArray.filter((tab) => tab.action)

const activateTab = async (tab: TabDisplay) => {
if (tab.action) {
await tab.action()
} else {
setActiveTab(tab.inputKey)
}
}

const activateTabFromClick = (tab: TabDisplay) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
activateTab(tab)
}

return (
<>
<Box
Expand All @@ -126,27 +169,25 @@ export const TabPanel: React.FunctionComponent<TabPanelProps> = ({tabs, initialA
borderTop
>
<Box ref={contentTabsRef} flexDirection="row" flexWrap="nowrap" flexShrink={0} marginRight={3}>
<Text wrap="truncate-end">
{'│'}
{contentTabs.map((tab) => {
return (
<React.Fragment key={tab.inputKey}>
<Text bold={activeTab === tab.inputKey} inverse={activeTab === tab.inputKey} wrap="truncate">
{tab.header}
</Text>
{'│'}
</React.Fragment>
)
})}
</Text>
<Text>│</Text>
{contentTabs.map((tab) => (
<React.Fragment key={tab.inputKey}>
<ClickableTab
active={activeTab === tab.inputKey}
header={tab.header}
onClick={() => activateTabFromClick(tab)}
/>
<Text>│</Text>
</React.Fragment>
))}
</Box>
{displayActions && (
<Box flexGrow={1} justifyContent="flex-end">
{actionTabs.map((tab, index) => (
<Text wrap="truncate" key={tab.inputKey}>
({tab.inputKey}) {tab.label}
{index < actionTabs.length - 1 && ''}
</Text>
<React.Fragment key={tab.inputKey}>
<ClickableTab header={`(${tab.inputKey}) ${tab.label}`} onClick={() => activateTabFromClick(tab)} />
{index < actionTabs.length - 1 && <Text></Text>}
</React.Fragment>
))}
</Box>
)}
Expand Down
1 change: 1 addition & 0 deletions packages/cli-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"@bugsnag/js": "8.9.0",
"@graphql-typed-document-node/core": "3.2.0",
"@iarna/toml": "2.2.5",
"@ink-tools/ink-mouse": "2.1.0",
"@oclif/core": "4.8.3",
"@shopify/polaris": "12.27.0",
"@shopify/polaris-icons": "8.11.1",
Expand Down
18 changes: 18 additions & 0 deletions packages/cli-kit/src/private/node/conf-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
runAtMinimumInterval,
getConfigStoreForPartnerStatus,
getCachedPartnerAccountStatus,
getMouseEnabled,
setCachedPartnerAccountStatus,
setMouseEnabled,
runWithRateLimit,
} from './conf-store.js'
import {isLocalEnvironment} from './context/service.js'
Expand Down Expand Up @@ -74,6 +76,22 @@ describe('removeSession', () => {
})
})

describe('mouse preference', () => {
test('is enabled by default and persists an explicit preference', async () => {
await inTemporaryDirectory(async (cwd) => {
const config = new LocalStorage<ConfSchema>({cwd})

expect(getMouseEnabled(config)).toBe(true)

setMouseEnabled(false, config)
expect(getMouseEnabled(config)).toBe(false)

setMouseEnabled(true, config)
expect(getMouseEnabled(config)).toBe(true)
})
})
})

describe('getCurrentSessionId', () => {
test('returns the content of the currentSessionId key in production', async () => {
await inTemporaryDirectory(async (cwd) => {
Expand Down
20 changes: 20 additions & 0 deletions packages/cli-kit/src/private/node/conf-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface ConfSchema {
currentDevSessionId?: string
cache?: Cache
autoUpgradeEnabled?: boolean
mouseEnabled?: boolean
}

let _instance: LocalStorage<ConfSchema> | undefined
Expand Down Expand Up @@ -285,6 +286,25 @@ export function setAutoUpgradeEnabled(enabled: boolean, config: LocalStorage<Con
config.set('autoUpgradeEnabled', enabled)
}

/**
* Get mouse interaction preference.
* Defaults to true if the preference has never been explicitly set.
*
* @returns Whether mouse interactions are enabled.
*/
export function getMouseEnabled(config: LocalStorage<ConfSchema> = cliKitStore()): boolean {
return config.get('mouseEnabled') ?? true
}

/**
* Set mouse interaction preference.
*
* @param enabled - Whether mouse interactions should be enabled.
*/
export function setMouseEnabled(enabled: boolean, config: LocalStorage<ConfSchema> = cliKitStore()): void {
config.set('mouseEnabled', enabled)
}

export function getConfigStoreForPartnerStatus() {
return new LocalStorage<Record<string, {status: true; checkedAt: string}>>({
projectName: 'shopify-cli-kit-partner-status',
Expand Down
12 changes: 10 additions & 2 deletions packages/cli-kit/src/private/node/testing/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class Stderr extends EventEmitter {

export class Stdin extends EventEmitter {
isTTY: boolean
isRaw = false
data: string | null = null

constructor(options: {isTTY?: boolean} = {}) {
Expand All @@ -28,10 +29,16 @@ export class Stdin extends EventEmitter {
write = (data: string) => {
this.data = data
this.emit('readable')
this.emit('data', data)
}

setEncoding() {}
setRawMode() {}
setRawMode(isRaw: boolean) {
this.isRaw = isRaw
}

pause() {}
resume() {}
ref() {}
unref() {}
read: () => string | null = () => {
Expand Down Expand Up @@ -59,10 +66,11 @@ interface RenderOptions {
stdout?: EventEmitter
stderr?: EventEmitter
stdin?: EventEmitter
stdoutIsTTY?: boolean
}

export const render = (tree: ReactElement, options: RenderOptions = {}): Instance => {
const stdout = new Stdout({columns: 100})
const stdout = Object.assign(new Stdout({columns: 100}), {isTTY: options.stdoutIsTTY ?? false})
const stderr = new Stderr()
const stdin = new Stdin()

Expand Down
Loading
Loading