diff --git a/.changeset/clickable-options-and-tabs.md b/.changeset/clickable-options-and-tabs.md deleted file mode 100644 index 259f98f97d9..00000000000 --- a/.changeset/clickable-options-and-tabs.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@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. diff --git a/.changeset/filter-app-dev-logs.md b/.changeset/filter-app-dev-logs.md new file mode 100644 index 00000000000..c628f067a48 --- /dev/null +++ b/.changeset/filter-app-dev-logs.md @@ -0,0 +1,7 @@ +--- +'@shopify/app': minor +'@shopify/cli': minor +'@shopify/cli-kit': minor +--- + +Full-screen layout for `shopify app dev`, including a log filter. diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index 26ac10c63ee..2b85bb45397 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -1,5 +1,5 @@ import Dev from './dev.js' -import {dev} from '../../services/dev.js' +import {dev, type DevOptions} from '../../services/dev.js' import {linkedAppContext} from '../../services/app-context.js' import {storeContext} from '../../services/store-context.js' import {getTunnelMode} from '../../services/dev/tunnel-mode.js' @@ -24,13 +24,20 @@ vi.mock('../../models/app/loader.js') vi.mock('@shopify/cli-kit/node/metadata') describe('app dev command', () => { + let resolvedDevOptions: DevOptions | undefined + beforeEach(() => { vi.mocked(dev).mockReset() + vi.mocked(dev).mockImplementation(async (commandOptions) => { + resolvedDevOptions = commandOptions + return resolvedDevOptions.app + }) vi.mocked(linkedAppContext).mockReset() vi.mocked(storeContext).mockReset() vi.mocked(getTunnelMode).mockReset() vi.mocked(checkFolderIsValidApp).mockReset() vi.mocked(addPublicMetadata).mockReset() + resolvedDevOptions = undefined }) test('does not require --use-localhost when --install-mkcert is not passed', async () => { @@ -59,6 +66,7 @@ describe('app dev command', () => { localhostPort: undefined, }) expect(dev).toHaveBeenCalledWith(expect.objectContaining({installMkcert: false, tunnel: {mode: 'auto'}})) + expect(resolvedDevOptions).toEqual(expect.objectContaining({installMkcert: false, tunnel: {mode: 'auto'}})) }) }) diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 387824ba0ac..ea79f31c0dd 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -108,54 +108,58 @@ export default class Dev extends AppLinkedCommand { public async run(): Promise { const {flags} = await this.parse(Dev) - const tunnelMode = await getTunnelMode({ - useLocalhost: flags['use-localhost'] ?? false, - tunnelUrl: flags['tunnel-url'], - localhostPort: flags['localhost-port'], - }) + const prepareDevOptions = async () => { + const tunnelMode = await getTunnelMode({ + useLocalhost: flags['use-localhost'] ?? false, + tunnelUrl: flags['tunnel-url'], + localhostPort: flags['localhost-port'], + }) - await addPublicMetadata(() => { - return { - cmd_app_dependency_installation_skipped: flags['skip-dependencies-installation'], - cmd_app_reset_used: flags.reset, - cmd_dev_tunnel_type: tunnelMode.mode, - } - }) + await addPublicMetadata(() => { + return { + cmd_app_dependency_installation_skipped: flags['skip-dependencies-installation'], + cmd_app_reset_used: flags.reset, + cmd_dev_tunnel_type: tunnelMode.mode, + } + }) + + await checkFolderIsValidApp(flags.path) - await checkFolderIsValidApp(flags.path) + const appContextResult = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, + }) + const store = await storeContext({ + appContextResult, + storeFqdn: flags.store, + forceReselectStore: flags.reset, + }) - const appContextResult = await linkedAppContext({ - directory: flags.path, - clientId: flags['client-id'], - forceRelink: flags.reset, - userProvidedConfigName: flags.config, - }) - const store = await storeContext({ - appContextResult, - storeFqdn: flags.store, - forceReselectStore: flags.reset, - }) + const devOptions: DevOptions = { + ...appContextResult, + store, + directory: flags.path, + update: !flags['no-update'], + skipDependenciesInstallation: flags['skip-dependencies-installation'], + commandConfig: this.config, + subscriptionProductUrl: flags['subscription-product-url'], + checkoutCartUrl: flags['checkout-cart-url'], + theme: flags.theme, + themeExtensionPort: flags['theme-app-extension-port'], + storePassword: flags['store-password'], + notify: flags.notify, + graphiqlPort: flags['graphiql-port'], + graphiqlKey: flags['graphiql-key'], + installMkcert: flags['install-mkcert'] ?? false, + tunnel: tunnelMode, + } - const devOptions: DevOptions = { - ...appContextResult, - store, - directory: flags.path, - update: !flags['no-update'], - skipDependenciesInstallation: flags['skip-dependencies-installation'], - commandConfig: this.config, - subscriptionProductUrl: flags['subscription-product-url'], - checkoutCartUrl: flags['checkout-cart-url'], - theme: flags.theme, - themeExtensionPort: flags['theme-app-extension-port'], - storePassword: flags['store-password'], - notify: flags.notify, - graphiqlPort: flags['graphiql-port'], - graphiqlKey: flags['graphiql-key'], - installMkcert: flags['install-mkcert'] ?? false, - tunnel: tunnelMode, + return devOptions } - await dev(devOptions) - return {app: appContextResult.app} + const app = await dev(await prepareDevOptions()) + return {app} } } diff --git a/packages/app/src/cli/services/dev.test.ts b/packages/app/src/cli/services/dev.test.ts index 119576f884d..79d7c477406 100644 --- a/packages/app/src/cli/services/dev.test.ts +++ b/packages/app/src/cli/services/dev.test.ts @@ -2,6 +2,7 @@ import {dev, blockIfMigrationIncomplete} from './dev.js' import {setupDevProcesses} from './dev/processes/setup-dev-processes.js' import {renderDev} from './dev/ui.js' import {fetchAppRemoteConfiguration} from './app/select-app.js' +import {installAppDependencies} from './dependencies.js' import { testAppLinked, testDeveloperPlatformClient, @@ -11,20 +12,30 @@ import { testProject, } from '../models/app/app.test-data.js' import metadata from '../metadata.js' -import {describe, expect, test, vi} from 'vitest' +import {beforeEach, describe, expect, test, vi} from 'vitest' import {hashString} from '@shopify/cli-kit/node/crypto' import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' vi.mock('./dev/fetch.js') vi.mock('./dev/processes/setup-dev-processes.js') vi.mock('./dev/ui.js') vi.mock('./app/select-app.js') +vi.mock('./dependencies.js') vi.mock('@shopify/cli-kit/node/analytics') vi.mock('@shopify/cli-kit/node/tcp') vi.mock('../utilities/mkcert.js') +vi.mock('@shopify/cli-kit/node/system') +vi.mock('@shopify/cli-kit/node/ui') describe('dev', () => { + beforeEach(() => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) + }) + test('logs store domain metadata when launching dev', async () => { const store = testOrganizationStore({shopDomain: 'dev-store.myshopify.com'}) const app = testAppLinked() @@ -80,10 +91,82 @@ describe('dev', () => { }), ) expect(reportAnalyticsEvent).toHaveBeenCalledWith({config: {}, exitMode: 'ok'}) + expect(renderSingleTask).not.toHaveBeenCalled() addPublicMetadata.mockRestore() addSensitiveMetadata.mockRestore() }) + + test('renders the interactive layout before installing dependencies', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) + const app = testAppLinked() + const store = testOrganizationStore({shopDomain: 'dev-store.myshopify.com'}) + let loadingIndicatorStarted = false + let loadingIndicatorFinished = false + let dependencyInstallationStarted = () => {} + const dependencyInstallationStart = new Promise((resolve) => { + dependencyInstallationStarted = resolve + }) + let finishDependencyInstallation = () => {} + const dependencyInstallationFinished = new Promise((resolve) => { + finishDependencyInstallation = resolve + }) + + vi.mocked(fetchAppRemoteConfiguration).mockImplementation(async () => { + expect(loadingIndicatorStarted).toBe(true) + return {name: 'Remote app', application_url: '', embedded: true} + }) + vi.mocked(getAvailableTCPPort).mockResolvedValue(3456) + vi.mocked(checkPortAvailability).mockResolvedValue(true) + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { + loadingIndicatorStarted = true + const result = await task(() => {}) + loadingIndicatorFinished = true + return result + }) + vi.mocked(installAppDependencies).mockImplementation(async () => { + dependencyInstallationStarted() + await dependencyInstallationFinished + }) + vi.mocked(setupDevProcesses).mockResolvedValue({ + processes: [], + previewUrl: 'https://dev-store.myshopify.com/admin/apps/api-key', + graphiqlUrl: undefined, + devSessionStatusManager: {} as any, + }) + vi.mocked(renderDev).mockImplementation(async ({processes, abortController}) => { + expect(loadingIndicatorFinished).toBe(true) + await processes[0]?.action(process.stdout, process.stderr, abortController.signal) + }) + + const devPromise = dev({ + app, + project: testProject({usesWorkspaces: false}), + remoteApp: testOrganizationApp({apiKey: 'api-key'}), + organization: testOrganization(), + specifications: [], + developerPlatformClient: testDeveloperPlatformClient(), + store, + directory: app.directory, + update: false, + commandConfig: {} as any, + skipDependenciesInstallation: false, + tunnel: {mode: 'custom', url: 'https://localhost:3456'}, + }) + + await dependencyInstallationStart + expect(renderSingleTask).toHaveBeenCalledWith( + expect.objectContaining({title: expect.objectContaining({value: 'Starting dev preview'})}), + ) + expect(renderSingleTask).toHaveBeenCalledOnce() + expect(renderDev).toHaveBeenCalledOnce() + expect(setupDevProcesses).not.toHaveBeenCalled() + + finishDependencyInstallation() + await devPromise + + expect(setupDevProcesses).toHaveBeenCalledOnce() + }) }) describe('blockIfMigrationIncomplete', () => { diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index 24e0b135bb0..4fd571101d1 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -17,7 +17,6 @@ import {getCachedAppInfo, setCachedAppInfo} from './local-storage.js' import {fetchAppRemoteConfiguration} from './app/select-app.js' import {DevSessionStatusManager} from './dev/processes/dev-session/dev-session-status-manager.js' import {TunnelMode} from './dev/tunnel-mode.js' -import {PortDetail, renderPortWarnings} from './dev/port-warnings.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' import {Web, AppLinkedInterface} from '../models/app/app.js' import {Project} from '../models/project/project.js' @@ -36,9 +35,12 @@ import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/ import {TunnelClient} from '@shopify/cli-kit/node/plugins/tunnel' import {getBackendPort} from '@shopify/cli-kit/node/environment' import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' -import {OutputProcess} from '@shopify/cli-kit/node/output' +import {outputContent, OutputProcess} from '@shopify/cli-kit/node/output' import {hashString} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' export interface DevOptions { app: AppLinkedInterface @@ -64,15 +66,51 @@ export interface DevOptions { installMkcert?: boolean } -export async function dev(commandOptions: DevOptions) { - const config = await prepareForDev(commandOptions) +export async function dev(commandOptions: DevOptions): Promise { + const preparedEnvironment = terminalSupportsPrompting() + ? await renderSingleTask({ + title: outputContent`Starting dev preview`, + task: async () => prepareDevEnvironment(commandOptions), + }) + : await prepareDevEnvironment(commandOptions) + const config = await prepareForDev(commandOptions, preparedEnvironment) + + if (terminalSupportsPrompting()) { + const devSessionStatusManager = new DevSessionStatusManager() + devSessionStatusManager.setMessage('LOADING') + const setupPromise = (async () => { + await actionsBeforeSettingUpDevProcesses(config) + return setupDevProcesses(config, devSessionStatusManager) + })() + await launchDevProcessesDuringSetup({setupPromise, config, devSessionStatusManager}) + return commandOptions.app + } + await actionsBeforeSettingUpDevProcesses(config) const {processes, graphiqlUrl, previewUrl, devSessionStatusManager} = await setupDevProcesses(config) await actionsBeforeLaunchingDevProcesses(config) await launchDevProcesses({processes, previewUrl, graphiqlUrl, config, devSessionStatusManager}) + return commandOptions.app } -async function prepareForDev(commandOptions: DevOptions): Promise { +async function prepareForDev( + commandOptions: DevOptions, + {config, webs, cachedUpdateURLs, apiKey}: Awaited>, +): Promise { + const partnerUrlsUpdated = await handleUpdatingOfPartnerUrls( + webs, + commandOptions.update, + config.network, + config.localApp, + cachedUpdateURLs, + config.remoteApp, + apiKey, + ) + + return {...config, partnerUrlsUpdated} +} + +async function prepareDevEnvironment(commandOptions: DevOptions) { const {app, remoteApp, developerPlatformClient, store, specifications, tunnel} = commandOptions // Be optimistic about tunnel creation and do it as early as possible @@ -91,14 +129,16 @@ async function prepareForDev(commandOptions: DevOptions): Promise { ) remoteApp.configuration = remoteConfiguration - showReusedDevValues({ - app, - remoteApp, - selectedStore: store, - cachedInfo: getCachedAppInfo(commandOptions.directory), - organization: commandOptions.organization, - tunnelMode: tunnel.mode, - }) + if (!terminalSupportsPrompting()) { + showReusedDevValues({ + app, + remoteApp, + selectedStore: store, + cachedInfo: getCachedAppInfo(commandOptions.directory), + organization: commandOptions.organization, + tunnelMode: tunnel.mode, + }) + } // If the dev_store_url is set in the app configuration, keep updating it. // If not, `store-context.ts` will take care of caching it in the hidden config. @@ -111,30 +151,7 @@ async function prepareForDev(commandOptions: DevOptions): Promise { await configFile.patch({build: {dev_store_url: store.shopDomain}}) } - if (!commandOptions.skipDependenciesInstallation && !commandOptions.project.usesWorkspaces) { - await installAppDependencies(commandOptions.project) - } - const graphiqlPort = commandOptions.graphiqlPort ?? (await getAvailableTCPPort(ports.graphiql)) - const portDetails: PortDetail[] = [ - { - for: 'GraphiQL', - flagToRemedy: '--graphiql-port', - requested: commandOptions.graphiqlPort ?? ports.graphiql, - actual: graphiqlPort, - }, - ] - - if (tunnel.mode === 'use-localhost') { - portDetails.push({ - for: 'localhost', - flagToRemedy: '--localhost-port', - requested: tunnel.requestedPort, - actual: tunnel.actualPort, - }) - } - - renderPortWarnings(portDetails) const {webs, ...network} = await setupNetworkingOptions( app.directory, @@ -151,32 +168,29 @@ async function prepareForDev(commandOptions: DevOptions): Promise { const previousAppId = getCachedAppInfo(commandOptions.directory)?.previousAppId const apiKey = remoteApp.apiKey - const partnerUrlsUpdated = await handleUpdatingOfPartnerUrls( + return { + config: { + storeFqdn: store.shopDomain, + storeId: store.shopId, + remoteApp, + remoteAppUpdated: remoteApp.apiKey !== previousAppId, + localApp: app, + developerPlatformClient, + commandOptions, + network, + graphiqlPort, + graphiqlKey: commandOptions.graphiqlKey, + }, webs, - commandOptions.update, - network, - app, cachedUpdateURLs, - remoteApp, apiKey, - ) - - return { - storeFqdn: store.shopDomain, - storeId: store.shopId, - remoteApp, - remoteAppUpdated: remoteApp.apiKey !== previousAppId, - localApp: app, - developerPlatformClient, - commandOptions, - network, - partnerUrlsUpdated, - graphiqlPort, - graphiqlKey: commandOptions.graphiqlKey, } } async function actionsBeforeSettingUpDevProcesses(devConfig: DevConfig) { + if (!devConfig.commandOptions.skipDependenciesInstallation && !devConfig.commandOptions.project.usesWorkspaces) { + await installAppDependencies(devConfig.commandOptions.project) + } await blockIfMigrationIncomplete(devConfig) } @@ -326,8 +340,51 @@ async function launchDevProcesses({ config: DevConfig devSessionStatusManager: DevSessionStatusManager }) { - const abortController = new AbortController() - const processesForTaskRunner: OutputProcess[] = processes.map((process) => { + const processesForTaskRunner = outputProcesses(processes) + return renderDevProcesses({ + processes: processesForTaskRunner, + previewUrl, + graphiqlUrl, + config, + devSessionStatusManager, + }) +} + +async function launchDevProcessesDuringSetup({ + setupPromise, + config, + devSessionStatusManager, +}: { + setupPromise: ReturnType + config: DevConfig + devSessionStatusManager: DevSessionStatusManager +}) { + const setupProcess: OutputProcess = { + prefix: 'app-preview', + action: async (stdout, stderr, signal) => { + const {processes} = await setupPromise + if (signal.aborted) return + + await actionsBeforeLaunchingDevProcesses(config) + await Promise.all( + outputProcesses(processes).map((process) => + useConcurrentOutputContext({outputPrefix: process.prefix}, () => process.action(stdout, stderr, signal)), + ), + ) + }, + } + + return renderDevProcesses({ + processes: [setupProcess], + previewUrl: '', + graphiqlUrl: undefined, + config, + devSessionStatusManager, + }) +} + +function outputProcesses(processes: DevProcesses): OutputProcess[] { + return processes.map((process) => { const outputProcess: OutputProcess = { prefix: process.prefix, action: async (stdout, stderr, signal) => { @@ -337,6 +394,22 @@ async function launchDevProcesses({ } return outputProcess }) +} + +function renderDevProcesses({ + processes, + previewUrl, + graphiqlUrl, + config, + devSessionStatusManager, +}: { + processes: OutputProcess[] + previewUrl: string + graphiqlUrl: string | undefined + config: DevConfig + devSessionStatusManager: DevSessionStatusManager +}) { + const abortController = new AbortController() const developerPlatformClient = config.developerPlatformClient const app = { @@ -345,7 +418,7 @@ async function launchDevProcesses({ } return renderDev({ - processes: processesForTaskRunner, + processes, previewUrl, graphiqlUrl, app, @@ -357,6 +430,16 @@ async function launchDevProcesses({ organizationName: config.commandOptions.organization.businessName, configPath: config.localApp.configPath, localURL: config.network.proxyUrl, + usingLocalhost: config.commandOptions.tunnel.mode === 'use-localhost', + unavailableGraphiqlPort: + config.graphiqlPort === (config.commandOptions.graphiqlPort ?? ports.graphiql) + ? undefined + : (config.commandOptions.graphiqlPort ?? ports.graphiql), + localhostPortUnavailable: + config.commandOptions.tunnel.mode === 'use-localhost' && + config.commandOptions.tunnel.requestedPort !== config.commandOptions.tunnel.actualPort + ? config.commandOptions.tunnel.requestedPort + : undefined, }) } diff --git a/packages/app/src/cli/services/dev/port-warnings.test.ts b/packages/app/src/cli/services/dev/port-warnings.test.ts deleted file mode 100644 index 77e00babeef..00000000000 --- a/packages/app/src/cli/services/dev/port-warnings.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import {PortDetail, renderPortWarnings} from './port-warnings.js' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {describe, expect, test} from 'vitest' - -describe('renderPortWarnings()', () => { - test('does not call renderWarning when no port details', () => { - // Given - const mockOutput = mockAndCaptureOutput() - const portDetails: PortDetail[] = [] - - // When - mockOutput.clear() - renderPortWarnings(portDetails) - - // Then - expect(mockOutput.warn()).toBe('') - }) - - test('does not call renderWarning when request & actual ports match', () => { - // Given - const mockOutput = mockAndCaptureOutput() - const portDetails: PortDetail[] = [ - { - for: 'GraphiQL', - flagToRemedy: '--graphiql-port', - requested: 5678, - actual: 5678, - }, - { - for: 'localhost', - flagToRemedy: '--localhost-port', - requested: 1234, - actual: 1234, - }, - ] - - // When - mockOutput.clear() - renderPortWarnings(portDetails) - - // Then - expect(mockOutput.warn()).toBe('') - }) - - test('calls renderWarning once when there is one warning', () => { - // Given - const mockOutput = mockAndCaptureOutput() - const portDetails: PortDetail[] = [ - { - for: 'GraphiQL', - flagToRemedy: '--graphiql-port', - requested: 4321, - actual: 4321, - }, - { - for: 'localhost', - flagToRemedy: '--localhost-port', - requested: 1234, - actual: 4567, - }, - ] - - // When - mockOutput.clear() - renderPortWarnings(portDetails) - - // Then - expect(mockOutput.warn()).toContain('A random port will be used for localhost because 1234 is not available.') - expect(mockOutput.warn()).toContain('If you want to use a specific port, you can choose a different one by') - expect(mockOutput.warn()).toContain('setting the `--localhost-port` flag.') - }) - - test('Calls renderWarning once, combining warnings when there are multiple warnings', () => { - // Given - const mockOutput = mockAndCaptureOutput() - const portDetails: PortDetail[] = [ - { - for: 'localhost', - flagToRemedy: '--localhost-port', - requested: 4567, - actual: 7654, - }, - { - for: 'GraphiQL', - flagToRemedy: '--graphiql-port', - requested: 1234, - actual: 4321, - }, - ] - - // When - mockOutput.clear() - renderPortWarnings(portDetails) - - // Then - expect(mockOutput.warn()).toContain('Random ports will be used for localhost and GraphiQL because the requested') - expect(mockOutput.warn()).toContain('ports are not available') - expect(mockOutput.warn()).toContain('If you want to use specific ports, you can choose different ports using') - expect(mockOutput.warn()).toContain('the `--localhost-port` and `--graphiql-port` flags.') - }) -}) diff --git a/packages/app/src/cli/services/dev/port-warnings.ts b/packages/app/src/cli/services/dev/port-warnings.ts deleted file mode 100644 index 49e997988e3..00000000000 --- a/packages/app/src/cli/services/dev/port-warnings.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {asHumanFriendlyArray} from '@shopify/cli-kit/common/array' -import {renderWarning} from '@shopify/cli-kit/node/ui' - -export type PortDetail = ( - | { - for: 'GraphiQL' - flagToRemedy: '--graphiql-port' - } - | { - for: 'localhost' - flagToRemedy: '--localhost-port' - } -) & { - requested: number - actual: number -} - -export function renderPortWarnings(portDetails: PortDetail[]) { - if (!portDetails.length) return - - const portWarnings = portDetails.filter((warning) => warning.requested !== warning.actual) - - if (portWarnings.length === 0) return - - if (portWarnings.length === 1 && portWarnings[0]) { - const warning = portWarnings[0] - - renderWarning({ - headline: [`A random port will be used for ${warning.for} because ${warning?.requested} is not available.`], - body: [ - `If you want to use a specific port, you can choose a different one by setting the `, - {command: warning?.flagToRemedy}, - ` flag.`, - ], - }) - return - } - - const formattedWarningTypes = asHumanFriendlyArray(portWarnings.map((warning) => warning.for)).join(' ') - const formattedFlags = asHumanFriendlyArray(portWarnings.map((warning) => ({command: warning.flagToRemedy}))) - - renderWarning({ - headline: [`Random ports will be used for ${formattedWarningTypes} because the requested ports are not available.`], - body: [`If you want to use specific ports, you can choose different ports using the`, ...formattedFlags, `flags.`], - }) -} diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index de0626c6c3a..22ef29691e0 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -5,6 +5,7 @@ import {WebProcess, launchWebProcess} from './web.js' import {PreviewableExtensionProcess, launchPreviewableExtensionProcess} from './previewable-extension.js' import {launchGraphiQLServer} from './graphiql.js' import {pushUpdatesForDevSession} from './dev-session/dev-session-process.js' +import {DevSessionStatusManager} from './dev-session/dev-session-status-manager.js' import {runThemeAppExtensionsServer} from './theme-app-extension.js' import {launchAppWatcher} from './app-watcher-process.js' import { @@ -145,21 +146,31 @@ describe('setup-dev-processes', () => { const graphiqlKey = 'somekey' - const res = await setupDevProcesses({ - localApp, - commandOptions, - network, - remoteApp, - remoteAppUpdated, - storeFqdn, - storeId, - developerPlatformClient, - partnerUrlsUpdated: true, - graphiqlPort, - graphiqlKey, - }) + const devSessionStatusManager = new DevSessionStatusManager() + devSessionStatusManager.setMessage('LOADING') + const res = await setupDevProcesses( + { + localApp, + commandOptions, + network, + remoteApp, + remoteAppUpdated, + storeFqdn, + storeId, + developerPlatformClient, + partnerUrlsUpdated: true, + graphiqlPort, + graphiqlKey, + }, + devSessionStatusManager, + ) expect(res.previewUrl).toBe('https://admin.shopify.com/store/store/apps/api-key?dev-console=show') + expect(res.devSessionStatusManager).toBe(devSessionStatusManager) + expect(res.devSessionStatusManager.status).toMatchObject({ + previewURL: res.previewUrl, + statusMessage: {message: 'Preparing dev preview', type: 'loading'}, + }) expect(res.processes[0]).toMatchObject({ type: 'web', prefix: 'web-backend-frontend', diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 1277bb675d0..8dded539d06 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -70,18 +70,21 @@ export interface DevConfig { graphiqlKey?: string } -export async function setupDevProcesses({ - localApp, - remoteAppUpdated, - developerPlatformClient, - remoteApp, - storeFqdn, - storeId, - commandOptions, - network, - graphiqlPort, - graphiqlKey, -}: DevConfig): Promise<{ +export async function setupDevProcesses( + { + localApp, + remoteAppUpdated, + developerPlatformClient, + remoteApp, + storeFqdn, + storeId, + commandOptions, + network, + graphiqlPort, + graphiqlKey, + }: DevConfig, + devSessionStatusManager = new DevSessionStatusManager(), +): Promise<{ processes: DevProcesses previewUrl: string graphiqlUrl: string | undefined @@ -107,7 +110,7 @@ export async function setupDevProcesses({ const appEmbedded = reloadedApp.configuration.embedded const hasExtensions = reloadedApp.nonConfigExtensions.length > 0 - const devSessionStatusManager = new DevSessionStatusManager({ + devSessionStatusManager.updateStatus({ isReady: false, previewURL, graphiqlURL, diff --git a/packages/app/src/cli/services/dev/ui.test.tsx b/packages/app/src/cli/services/dev/ui.test.tsx index ad6e27c29e8..61e246d8381 100644 --- a/packages/app/src/cli/services/dev/ui.test.tsx +++ b/packages/app/src/cli/services/dev/ui.test.tsx @@ -2,18 +2,51 @@ import {renderDev} from './ui.js' import {DevSessionUI} from './ui/components/DevSessionUI.js' import {DevSessionStatusManager} from './processes/dev-session/dev-session-status-manager.js' import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' -import {afterEach, describe, expect, test, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' import {AbortController} from '@shopify/cli-kit/node/abort' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import {render, renderInfo} from '@shopify/cli-kit/node/ui' +import {ReactElement} from 'react' vi.mock('@shopify/cli-kit/node/system') vi.mock('./ui/components/DevSessionUI.js') +vi.mock('@shopify/cli-kit/node/ui', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/ui') + return {...actual, render: vi.fn(), renderInfo: vi.fn()} +}) const developerPlatformClient = testDeveloperPlatformClient() const devSessionStatusManager = new DevSessionStatusManager() +let standardOutput: ReturnType + +function captureStandardOutput() { + const chunks: (string | Uint8Array)[] = [] + const write = vi.spyOn(process.stdout, 'write').mockImplementation((( + chunk: string | Uint8Array, + encodingOrCallback?: unknown, + callback?: unknown, + ) => { + chunks.push(chunk) + const writeComplete = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback + if (typeof writeComplete === 'function') writeComplete() + return true + }) as typeof process.stdout.write) + + return { + output: () => chunks.map((chunk) => (typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString())).join(''), + write, + restore: () => write.mockRestore(), + } +} + +beforeEach(() => { + standardOutput = captureStandardOutput() +}) afterEach(() => { + standardOutput.restore() + devSessionStatusManager.reset() mockAndCaptureOutput().clear() }) @@ -47,7 +80,6 @@ describe('ui', () => { test("shows preview and GraphiQL URLs when terminal doesn't support TTY", async () => { vi.mocked(terminalSupportsPrompting).mockReturnValue(false) - const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) const concurrentProcess = { prefix: 'prefix', action: vi.fn(async (_stdout, _stderr, _signal) => {}), @@ -63,11 +95,9 @@ describe('ui', () => { devSessionStatusManager, }) - const output = write.mock.calls.map(([message]) => message).join('') + const output = standardOutput.output() expect(output).toContain('Preview URL: https://lala.cloudflare.io/') expect(output).toContain('GraphiQL URL (Admin API): https://lala.cloudflare.io/graphiql') - - write.mockRestore() }) test('renders DevSessionUI when terminal supports TTY', async () => { @@ -78,8 +108,7 @@ describe('ui', () => { } const abortController = new AbortController() - // eslint-disable-next-line @typescript-eslint/no-floating-promises - renderDev({ + await renderDev({ processes: [concurrentProcess], previewUrl: 'https://lala.cloudflare.io/', graphiqlUrl: 'https://lala.cloudflare.io/graphiql', @@ -93,21 +122,70 @@ describe('ui', () => { abortController, shopFqdn: 'mystore.shopify.io', devSessionStatusManager, + configPath: '/app/shopify.app.toml', + usingLocalhost: true, + unavailableGraphiqlPort: 4000, + localhostPortUnavailable: 8081, }) - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(vi.mocked(DevSessionUI)).toHaveBeenCalledWith( + expect(vi.mocked(render)).toHaveBeenCalledWith( expect.objectContaining({ - processes: [concurrentProcess], - abortController, - devSessionStatusManager, - onAbort: expect.any(Function), + type: DevSessionUI, + props: expect.objectContaining({ + processes: [concurrentProcess], + abortController, + devSessionStatusManager, + configPath: '/app/shopify.app.toml', + usingLocalhost: true, + unavailableGraphiqlPort: 4000, + localhostPortUnavailable: 8081, + onAbort: expect.any(Function), + }), }), - // React 19 no longer passes legacy context as second argument - undefined, + expect.objectContaining({exitOnCtrlC: false, stdout: expect.anything()}), ) expect(concurrentProcess.action).not.toHaveBeenCalled() + + standardOutput.write.mockClear() + const renderOptions = vi.mocked(render).mock.calls[0]?.[1] + renderOptions?.stdout?.write('before\u001B[2J\u001B[3J\u001B[Hfirst\nsecond') + expect(standardOutput.write).toHaveBeenCalledWith('before\u001B[H\u001B[2Kfirst\n\u001B[2Ksecond') + }) + + test('prints the complete log history before the persistent preview notice', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) + devSessionStatusManager.updateStatus({isReady: true}) + const logLines = Array.from({length: 100}, (_, index) => `unique log ${index + 1}`) + vi.mocked(render).mockImplementation(async (element) => { + const devSessionElement = element as ReactElement<{ + onOutput: (chunk: {lines: string[]; prefix: string; timestamp: string}) => void + }> + devSessionElement.props.onOutput({lines: logLines, prefix: 'backend', timestamp: '12:34:56'}) + }) + + await renderDev({ + processes: [], + previewUrl: '', + app: {id: '123', developerPlatformClient}, + abortController: new AbortController(), + shopFqdn: 'mystore.shopify.io', + devSessionStatusManager, + }) + + const output = standardOutput.output() + const expectedHistory = logLines.map((line) => `12:34:56 │ ${'backend'.padStart(25)} │ ${line}`).join('\n') + expect(output).toBe(`\u001B[H\u001B[0J${expectedHistory}\n`) + expect(renderInfo).toHaveBeenCalledWith({ + headline: 'A preview of your development changes is still available on mystore.shopify.io.', + body: ['Run', {command: 'shopify app dev clean'}, 'to restore the latest released version of your app.'], + link: { + label: 'Learn more about dev previews', + url: 'https://shopify.dev/beta/developer-dashboard/shopify-app-dev', + }, + }) + expect(standardOutput.write.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(renderInfo).mock.invocationCallOrder[0]!, + ) }) test('calls devSessionDelete when DevSessionUI aborts', async () => { @@ -121,8 +199,7 @@ describe('ui', () => { } const shopFqdn = 'mystore.shopify.io' - // eslint-disable-next-line @typescript-eslint/no-floating-promises - renderDev({ + await renderDev({ processes: [ { prefix: 'prefix', @@ -137,9 +214,10 @@ describe('ui', () => { devSessionStatusManager, }) - await new Promise((resolve) => setTimeout(resolve, 10)) - - const onAbort = vi.mocked(DevSessionUI).mock.calls[0]?.[0]?.onAbort + const devSessionElement = vi.mocked(render).mock.calls[0]?.[0] as ReactElement<{ + onAbort: () => Promise + }> + const onAbort = devSessionElement.props.onAbort await onAbort?.() expect(app.developerPlatformClient.devSessionDelete).toHaveBeenCalledWith({ diff --git a/packages/app/src/cli/services/dev/ui.tsx b/packages/app/src/cli/services/dev/ui.tsx index 9eb23042433..44681c71f71 100644 --- a/packages/app/src/cli/services/dev/ui.tsx +++ b/packages/app/src/cli/services/dev/ui.tsx @@ -1,12 +1,65 @@ import {DevSessionUI} from './ui/components/DevSessionUI.js' +import {devPreviewInfo} from './ui/dev-preview-info.js' import {DevSessionStatusManager} from './processes/dev-session/dev-session-status-manager.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {OutputProcess} from '@shopify/cli-kit/node/output' import {AbortController} from '@shopify/cli-kit/node/abort' import React from 'react' -import {render} from '@shopify/cli-kit/node/ui' +import {render, renderInfo} from '@shopify/cli-kit/node/ui' +import {type ConcurrentOutputChunk} from '@shopify/cli-kit/node/ui/components' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +const CLEAR_TERMINAL = '\u001B[2J\u001B[3J\u001B[H' +const CURSOR_HOME = '\u001B[H' +const ERASE_LINE = '\u001B[2K' +const ERASE_FROM_CURSOR_TO_END = '\u001B[0J' +const MAX_PREFIX_COLUMN_SIZE = 25 + +function redrawCurrentViewport(chunk: string): string { + const redrawIndex = chunk.indexOf(CLEAR_TERMINAL) + if (redrawIndex === -1) return chunk + + const chunkBeforeRedraw = chunk.slice(0, redrawIndex) + const frame = chunk.slice(redrawIndex + CLEAR_TERMINAL.length) + const erasedFrame = frame.split('\n').join(`\n${ERASE_LINE}`) + return `${chunkBeforeRedraw}${CURSOR_HOME}${ERASE_LINE}${erasedFrame}` +} + +function stdoutWithInPlaceRedraw(stdout: NodeJS.WriteStream): NodeJS.WriteStream { + return new Proxy(stdout, { + get(target, property) { + if (property === 'write') { + return (chunk: string | Uint8Array, ...args: unknown[]) => { + const preservedChunk = typeof chunk === 'string' ? redrawCurrentViewport(chunk) : chunk + return Reflect.apply(target.write, target, [preservedChunk, ...args]) + } + } + + const value = Reflect.get(target, property, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) +} + +function formatLogHistory(chunks: ConcurrentOutputChunk[]): string { + const formattedLines = chunks.flatMap(({lines, prefix, timestamp}) => { + const formattedPrefix = prefix.slice(0, MAX_PREFIX_COLUMN_SIZE).padStart(MAX_PREFIX_COLUMN_SIZE) + return lines.map((line) => `${timestamp} │ ${formattedPrefix} │ ${line}`) + }) + + const history = formattedLines.length === 0 ? '' : `${formattedLines.join('\n')}\n` + return `${CURSOR_HOME}${ERASE_FROM_CURSOR_TO_END}${history}` +} + +async function writeToStdout(content: string): Promise { + await new Promise((resolve, reject) => { + process.stdout.write(content, (error) => { + if (error) reject(error) + else resolve() + }) + }) +} + interface DevProps { processes: OutputProcess[] previewUrl: string @@ -32,6 +85,9 @@ export async function renderDev({ organizationName, configPath, localURL, + usingLocalhost, + unavailableGraphiqlPort, + localhostPortUnavailable, }: DevProps & { devSessionStatusManager: DevSessionStatusManager appURL?: string @@ -39,27 +95,49 @@ export async function renderDev({ organizationName?: string configPath?: string localURL?: string + usingLocalhost?: boolean + unavailableGraphiqlPort?: number + localhostPortUnavailable?: number }) { if (terminalSupportsPrompting()) { - return render( - { - await app.developerPlatformClient.devSessionDelete({appId: app.id, shopFqdn}) - }} - />, - { - exitOnCtrlC: false, - }, - ) + const outputChunks: ConcurrentOutputChunk[] = [] + + try { + await render( + { + await app.developerPlatformClient.devSessionDelete({appId: app.id, shopFqdn}) + }} + onOutput={(chunk) => outputChunks.push(chunk)} + />, + { + exitOnCtrlC: false, + // Ink clears a full-height layout by saving each previous frame to scrollback. + // Redraw each line in place so only the final frame remains with earlier commands. + stdout: stdoutWithInPlaceRedraw(process.stdout), + }, + ) + } finally { + // Ink must release the terminal before normal output is restored, otherwise its cursor + // controls can overwrite log lines or interleave with the persistent preview notice. + await writeToStdout(formatLogHistory(outputChunks)) + if (devSessionStatusManager.status.isReady) { + renderInfo(devPreviewInfo(shopFqdn)) + } + } + return } await renderDevNonInteractive({ 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 7138dbc8280..b0922c58956 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 @@ -1,17 +1,12 @@ import {DevSessionUI} from './DevSessionUI.js' import {DevSessionStatus, DevSessionStatusManager} from '../../processes/dev-session/dev-session-status-manager.js' -import { - getLastFrameAfterUnmount, - render, - sendInputAndWait, - waitForContent, - waitForInputsToBeReady, -} from '@shopify/cli-kit/node/testing/ui' +import {render, sendInputAndWait, waitForContent, waitForInputsToBeReady} from '@shopify/cli-kit/node/testing/ui' import {AbortController} from '@shopify/cli-kit/node/abort' import React from 'react' import {beforeEach, describe, expect, test, vi} from 'vitest' import {unstyled} from '@shopify/cli-kit/node/output' import {openURL} from '@shopify/cli-kit/node/system' +import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' import {Writable} from 'stream' vi.mock('@shopify/cli-kit/node/system', async () => { @@ -34,6 +29,7 @@ vi.mock('@shopify/cli-kit/node/hooks/postrun', async () => { const mocks = vi.hoisted(() => { return { + getMouseEnabled: vi.fn(() => true), useStdin: vi.fn(() => { return {isRawModeSupported: true} }), @@ -41,6 +37,8 @@ const mocks = vi.hoisted(() => { } }) +vi.mock('@shopify/cli-kit/node/mouse', () => ({getMouseEnabled: mocks.getMouseEnabled})) + vi.mock('@shopify/cli-kit/node/ink', async () => { const actual = await vi.importActual('@shopify/cli-kit/node/ink') return { @@ -61,12 +59,37 @@ const initialStatus: DevSessionStatus = { const onAbort = vi.fn() +function mouseWheelDown(column: number, row: number): string { + return `\u001B[<65;${column};${row}M` +} + function mouseWheelUp(column: number, row: number): string { return `\u001B[<64;${column};${row}M` } +function mouseClick(column: number, row: number): [string, string] { + return [`\u001B[<0;${column};${row}M`, `\u001B[<0;${column};${row}m`] +} + +function mouseDrag(startColumn: number, startRow: number, endColumn: number, endRow: number): [string, string, string] { + return [ + `\u001B[<0;${startColumn};${startRow}M`, + `\u001B[<32;${endColumn};${endRow}M`, + `\u001B[<0;${endColumn};${endRow}m`, + ] +} + +function mouseClickOn(frame: string, text: string): [string, string] { + const lines = unstyled(frame).split('\n') + const rowIndex = lines.findIndex((line) => line.includes(text)) + const columnIndex = lines[rowIndex]?.indexOf(text) ?? -1 + if (rowIndex === -1 || columnIndex === -1) throw new Error(`Could not find ${text} in the rendered output`) + return mouseClick(columnIndex + 1, rowIndex + 1) +} + describe('DevSessionUI', () => { beforeEach(() => { + mocks.getMouseEnabled.mockReturnValue(true) mocks.terminalSupportsHyperlinks.mockReturnValue(false) mocks.useStdin.mockReturnValue({isRawModeSupported: true}) devSessionStatusManager = new DevSessionStatusManager() @@ -91,7 +114,69 @@ describe('DevSessionUI', () => { await waitForContent(renderInstance, 'Preparing dev preview') - expect(unstyled(renderInstance.lastFrame()!)).toMatch(/S[> ] Preparing dev preview \.\.\./) + expect(unstyled(renderInstance.lastFrame()!)).toMatch(/S[> ] Preparing dev preview\.\.\./) + + renderInstance.unmount() + }) + + test('renders configuration and GraphiQL port notices as initial app-preview logs', async () => { + const renderInstance = render( + , + ) + + await waitForContent(renderInstance, 'Using shopify.app.toml') + const output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('app-preview │ Using shopify.app.toml for default values.') + expect(output).toContain('app-preview │ ⚠️ `--use-localhost` is not compatible') + expect(output).toContain('app-preview │ ⚠️ A random port will be used for GraphiQL because 4000') + expect(output).toContain('app-preview │ ⚠️ A random port will be used for localhost because 8081') + + renderInstance.unmount() + }) + + test('reports all normalized output for rendering after exit', async () => { + let outputReadyResolve = () => {} + const outputReady = new Promise((resolve) => { + outputReadyResolve = resolve + }) + const onOutput = vi.fn() + const process = { + prefix: 'backend', + action: async (stdout: Writable) => { + stdout.write('\u001b[32mfirst line\nsecond line\u001b[39m') + outputReadyResolve() + await new Promise(() => {}) + }, + } + const renderInstance = render( + , + ) + + await outputReady + await waitForContent(renderInstance, 'second line') + + expect(onOutput).toHaveBeenCalledWith({ + lines: ['first line', 'second line'], + prefix: 'backend', + timestamp: expect.stringMatching(/^\d{2}:\d{2}:\d{2}$/), + }) renderInstance.unmount() }) @@ -169,13 +254,126 @@ describe('DevSessionUI', () => { expect(output).toContain('(g) Open GraphiQL (Admin API)') expect(output).toContain('(p) Open app preview') expect(output).toContain('(c) Open Dev Console for extension previews') - expect(output).toContain('Preview URL: https://shopify.com') - expect(output).toContain('GraphiQL URL: https://graphiql.shopify.com') - expect(output).toContain('Dev Console URL: https://mystore.myshopify.com/admin?dev-console=show') + expect(output).toContain('Open app preview: https://shopify.com') + expect(output).toContain('Open GraphiQL (Admin API): https://graphi') + expect(output).toContain('Open Dev Console for extension previews:') + expect(output).toContain('S> Shopify CLI') renderInstance.unmount() }) + test('cycles through log prefixes and only renders output for the selected prefix', async () => { + let processesStartedResolve: () => void + const processesStarted = new Promise((resolve) => { + processesStartedResolve = resolve + }) + let releaseProcesses = () => {} + const processesReleased = new Promise((resolve) => { + releaseProcesses = resolve + }) + let startedProcessCount = 0 + const processStarted = () => { + startedProcessCount++ + if (startedProcessCount === 3) processesStartedResolve() + } + let writeAppPreview = (_message: string) => {} + let writeAppHome = (_message: string) => {} + let writeWeb = (_message: string) => {} + let writeGraphiql = (_message: string) => {} + const appPreviewProcess = { + prefix: 'app-preview', + action: async (stdout: Writable) => { + writeAppPreview = (message) => stdout.write(message) + writeAppHome = (message) => useConcurrentOutputContext({outputPrefix: 'app_home'}, () => stdout.write(message)) + writeAppPreview('app preview message') + writeAppHome('app home message') + processStarted() + await processesReleased + }, + } + const webProcess = { + prefix: 'React Router', + action: async (stdout: Writable) => { + writeWeb = (message) => stdout.write(message) + writeWeb('react router message') + processStarted() + await processesReleased + }, + } + const graphiqlProcess = { + prefix: 'graphiql', + action: async (stdout: Writable) => { + writeGraphiql = (message) => stdout.write(message) + writeGraphiql('graphiql message') + processStarted() + await processesReleased + }, + } + + const renderInstance = render( + , + ) + await processesStarted + await waitForContent(renderInstance, 'app home message') + + let output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('(f) Filter logs: all') + const actionsRow = output.split('\n').find((line) => line.includes('(f) Filter logs: all'))! + expect(actionsRow.indexOf('(f) Filter logs: all')).toBeLessThan(actionsRow.indexOf('(q) Quit')) + expect(output).toContain('app preview message') + expect(output).toContain('react router message') + expect(output).toContain('app home message') + expect(output).toContain('graphiql message') + + await sendInputAndWait(renderInstance, 10, 'f') + writeAppHome('filtered app home message') + writeWeb('filtered react router message') + writeGraphiql('filtered graphiql message') + writeAppPreview('filtered app preview message') + await waitForContent(renderInstance, 'filtered app preview message') + output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('(f) Filter logs: app-previ') + expect(output).toContain('filtered app preview message') + expect(output).not.toContain('filtered react router message') + expect(output).not.toContain('filtered app home message') + expect(output).not.toContain('filtered graphiql message') + + await sendInputAndWait(renderInstance, 10, 'f', 'f', 'f') + writeAppPreview('app preview while filtering app home') + writeWeb('react router while filtering app home') + writeGraphiql('graphiql while filtering app home') + writeAppHome('app home while filtering app home') + await waitForContent(renderInstance, 'app home while filtering app home') + output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('(f) Filter logs: app_home') + expect(output).not.toContain('app preview while filtering app home') + expect(output).not.toContain('react router while filtering app home') + expect(output).toContain('app home while filtering app home') + expect(output).not.toContain('graphiql while filtering app home') + + await sendInputAndWait(renderInstance, 10, 'f') + writeAppPreview('app preview after resetting filter') + writeWeb('react router after resetting filter') + writeAppHome('app home after resetting filter') + writeGraphiql('graphiql after resetting filter') + await waitForContent(renderInstance, 'graphiql after resetting filter') + output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('(f) Filter logs: all') + expect(output).toContain('app preview after resetting filter') + expect(output).toContain('react router after resetting filter') + expect(output).toContain('app home after resetting filter') + expect(output).toContain('graphiql after resetting filter') + + releaseProcesses() + renderInstance.unmount() + }) + test('opens the previewURL when p is pressed', async () => { // When const renderInstance = render( @@ -348,7 +546,7 @@ describe('DevSessionUI', () => { renderInstance.unmount() }) - test('shows persistent dev info when aborting and dev preview is ready', async () => { + test('preserves the dev preview when aborting after it is ready', async () => { // Given const abortController = new AbortController() @@ -370,91 +568,7 @@ describe('DevSessionUI', () => { await promise - // Then - check final frame for key content without exact formatting - const finalOutput = unstyled(getLastFrameAfterUnmount(renderInstance)!) - - // Info message should be present - expect(finalOutput).toContain('A preview of your development changes is still available') - expect(finalOutput).toContain('mystore.myshopify.com') - expect(finalOutput).toContain('shopify app dev clean') - expect(finalOutput).toContain('Learn more about dev previews') - - // unmount so that polling is cleared after every test - renderInstance.unmount() - }) - - test('shows error shutting down message when aborted with error', async () => { - // Given - const abortController = new AbortController() - - const backendProcess: any = { - prefix: 'backend', - action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => { - stdout.write('first backend message') - stdout.write('second backend message') - stdout.write('third backend message') - - // await promise that never resolves - await new Promise(() => {}) - }, - } - - // When - const renderInstance = render( - , - ) - await waitForContent(renderInstance, 'third backend message') - - const promise = renderInstance.waitUntilExit() - - abortController.abort('something went wrong') - // Wait for React 19 to render the abort state - await waitForContent(renderInstance, 'something went wrong') - - // Then - check for key content without exact formatting - const output = unstyled(renderInstance.lastFrame()!) - - // Process output should be visible - expect(output).toContain('backend │ first backend message') - expect(output).toContain('backend │ second backend message') - expect(output).toContain('backend │ third backend message') - - // Info message should be present - expect(output).toContain('A preview of your development changes is still available') - expect(output).toContain('mystore.myshopify.com') - expect(output).toContain('shopify app dev clean') - expect(output).toContain('Learn more about dev previews') - - // Tab interface is hidden after abort (React 19 batches setIsAborted with other state updates) - expect(output).not.toContain('(d) Dev status') - - // Error message should be shown - expect(output).toContain('something went wrong') - - await promise - - // Then - check final frame for key content without exact formatting - const finalOutput = unstyled(getLastFrameAfterUnmount(renderInstance)!) - - // Process output should be visible - expect(finalOutput).toContain('backend │ first backend message') - expect(finalOutput).toContain('backend │ second backend message') - expect(finalOutput).toContain('backend │ third backend message') - - // Info message should be present - expect(finalOutput).toContain('A preview of your development changes is still available') - expect(finalOutput).toContain('mystore.myshopify.com') - expect(finalOutput).toContain('shopify app dev clean') - expect(finalOutput).toContain('Learn more about dev previews') - - // Error message should be shown - expect(finalOutput).toContain('something went wrong') + expect(onAbort).not.toHaveBeenCalled() // unmount so that polling is cleared after every test renderInstance.unmount() @@ -490,8 +604,8 @@ describe('DevSessionUI', () => { await waitForContent(renderInstance, 'Open app preview') // Then - expect(unstyled(renderInstance.lastFrame()!)).toContain('Preview URL: https://new-preview-url.shopify.com') - expect(unstyled(renderInstance.lastFrame()!)).toContain('GraphiQL URL: https://new-graphiql.shopify.com') + expect(unstyled(renderInstance.lastFrame()!)).toContain('Open app preview: https://new-preview-url') + expect(unstyled(renderInstance.lastFrame()!)).toContain('Open GraphiQL (Admin API): https://new-') renderInstance.unmount() }) @@ -525,8 +639,8 @@ describe('DevSessionUI', () => { // Then expect(unstyled(renderInstance.lastFrame()!)).toContain('(p)') expect(unstyled(renderInstance.lastFrame()!)).toContain('(g)') - expect(unstyled(renderInstance.lastFrame()!)).toContain('Preview URL: https://shopify.com') - expect(unstyled(renderInstance.lastFrame()!)).toContain('GraphiQL URL: https://graphiql.shopify.com') + expect(unstyled(renderInstance.lastFrame()!)).toContain('Open app preview: https://shopify.com') + expect(unstyled(renderInstance.lastFrame()!)).toContain('Open GraphiQL (Admin API): https://graphi') renderInstance.unmount() }) @@ -588,26 +702,180 @@ describe('DevSessionUI', () => { renderInstance.unmount() }) - test('temporarily releases mouse reporting when scrolling', async () => { + test('keeps the full-screen layout, mouse scrolling, and clicks working after filtering', async () => { + let outputReadyResolve = () => {} + const outputReady = new Promise((resolve) => { + outputReadyResolve = resolve + }) + const process = { + prefix: 'backend', + action: async (stdout: Writable) => { + stdout.write(Array.from({length: 100}, (_, index) => `[${String(index + 1).padStart(3, '0')}]`).join('\n')) + outputReadyResolve() + await new Promise(() => {}) + }, + } + const onOutput = vi.fn() + devSessionStatusManager.updateStatus({ + statusMessage: {message: 'Ready, watching for changes in your app', type: 'success'}, + }) const renderInstance = render( , {stdoutIsTTY: true}, ) - const stdoutWrite = vi.spyOn(renderInstance.stdout, 'write') + await outputReady + await waitForContent(renderInstance, '[100]') + const initialFrame = unstyled(renderInstance.lastFrame()!) + const initialLines = initialFrame.split('\n') + const initialMenuRow = initialLines.findIndex((line) => line.includes('(d) Dev status')) + const statusMessageRow = initialLines.findIndex((line) => line.includes('Ready, watching')) + const firstShortcutRow = initialLines.findIndex((line) => line.includes('(p) Open app preview')) + const quitMenuRow = initialLines.findIndex((line) => line.includes('(q) Quit')) + const logBoxTopRow = initialLines.findIndex((line) => line.startsWith('╭')) + const logBoxBottomRow = initialLines.findIndex((line) => line.startsWith('╰')) + const contentPanelTopRow = initialMenuRow + 1 + const footerBottomRow = initialLines.length - 1 + const informationPanelLastColumn = initialLines[footerBottomRow]!.indexOf('╯') + expect(initialLines).toHaveLength(80) + expect(initialLines[logBoxTopRow]).toHaveLength(100) + expect(initialLines.length - (logBoxBottomRow + 1)).toBe(9) + expect(contentPanelTopRow).toBe(initialMenuRow + 1) + expect(firstShortcutRow - statusMessageRow).toBe(2) + expect(initialLines[contentPanelTopRow]!.slice(1, 17)).not.toContain('─') + expect(initialLines[initialMenuRow]?.at(informationPanelLastColumn)).not.toBe('│') + expect(initialLines[statusMessageRow + 1]?.at(informationPanelLastColumn)).toBe('│') + expect(initialLines.slice(quitMenuRow - 1, quitMenuRow + 2).map((line) => line.at(-1))).toEqual(['╮', '│', '╯']) + expect(initialLines[footerBottomRow - 1]).toHaveLength(99) + expect(initialLines[footerBottomRow - 1]).toContain('S> Shopify CLI') + initialLines.slice(logBoxTopRow, logBoxBottomRow + 1).forEach((line) => { + expect(['╮', '│', '╯']).toContain(line.at(-1)) + }) + expect(initialFrame).not.toContain('Using shopify.app.toml') + + const textSelectionHint = + 'If you want to select text, try holding Option or Shift while dragging. Or disable mouse support with `shopify config mouse off`.' + await sendInputAndWait(renderInstance, 50, ...mouseDrag(2, logBoxTopRow + 2, 20, logBoxTopRow + 2)) + await waitForContent(renderInstance, 'If you want to select text') + await sendInputAndWait(renderInstance, 50, ...mouseClick(2, logBoxTopRow + 2)) + const textSelectionHintChunks = onOutput.mock.calls + .map(([chunk]) => chunk) + .filter(({prefix}) => prefix === 'app-preview') + expect(textSelectionHintChunks).toEqual([ + { + lines: [textSelectionHint], + prefix: 'app-preview', + timestamp: expect.stringMatching(/^\d{2}:\d{2}:\d{2}$/), + }, + ]) + + await sendInputAndWait(renderInstance, 50, ...mouseClickOn(renderInstance.lastFrame()!, '(f) Filter logs')) + await waitForContent(renderInstance, '[001]') + let filteredFrame = unstyled(renderInstance.lastFrame()!) + expect(filteredFrame).toContain('Filter logs: backend') + expect(filteredFrame).toContain('[001]') + expect(filteredFrame.split('\n').findIndex((line) => line.includes('(d) Dev status'))).toBe(initialMenuRow) + + const firstLogRow = filteredFrame.split('\n').findIndex((line) => line.includes('[001]')) + await sendInputAndWait(renderInstance, 50, mouseWheelDown(2, firstLogRow + 1)) + filteredFrame = unstyled(renderInstance.lastFrame()!) + expect(filteredFrame).not.toContain('[001]') + expect(filteredFrame).toContain('[004]') + expect(filteredFrame.split('\n').findIndex((line) => line.includes('(d) Dev status'))).toBe(initialMenuRow) + + await sendInputAndWait(renderInstance, 50, ...mouseClickOn(renderInstance.lastFrame()!, '(a) App info')) + const appInfoFrame = unstyled(renderInstance.lastFrame()!) + expect(appInfoFrame).toContain('My Test App') + expect(appInfoFrame.split('\n').findIndex((line) => line.includes('(d) Dev status'))).toBe(initialMenuRow) + renderInstance.unmount() + }) + + test('disables mouse interactions while keeping keyboard log scrolling available', async () => { + mocks.getMouseEnabled.mockReturnValue(false) + let outputReadyResolve = () => {} + const outputReady = new Promise((resolve) => { + outputReadyResolve = resolve + }) + const process = { + prefix: 'backend', + action: async (stdout: Writable) => { + stdout.write(Array.from({length: 100}, (_, index) => `[${String(index + 1).padStart(3, '0')}]`).join('\n')) + outputReadyResolve() + await new Promise(() => {}) + }, + } + const onOutput = vi.fn() + const renderInstance = render( + , + {stdoutIsTTY: true}, + ) + + await outputReady + await waitForContent(renderInstance, '[100]') + + const initialFrame = unstyled(renderInstance.lastFrame()!) + expect(initialFrame.split('\n')).toHaveLength(80) + expect(initialFrame).not.toContain('[001]') + expect(initialFrame).toContain('[100]') + + const firstLogRow = initialFrame.split('\n').findIndex((line) => line.includes('backend')) + await sendInputAndWait(renderInstance, 50, ...mouseDrag(2, firstLogRow + 1, 20, firstLogRow + 1)) + expect(onOutput.mock.calls.map(([chunk]) => chunk).filter(({prefix}) => prefix === 'app-preview')).toEqual([]) + expect(unstyled(renderInstance.lastFrame()!)).not.toContain('If you want to select text') + + await sendInputAndWait(renderInstance, 50, mouseWheelUp(2, firstLogRow + 1)) + const frameAfterMouseWheel = unstyled(renderInstance.lastFrame()!) + expect(frameAfterMouseWheel).toContain('[100]') + + await sendInputAndWait(renderInstance, 50, '\u001B[5~') + + const scrolledFrame = unstyled(renderInstance.lastFrame()!) + expect(scrolledFrame).toContain('[001]') + expect(scrolledFrame).not.toContain('[100]') + + await sendInputAndWait(renderInstance, 50, '\u001B[B') + expect(unstyled(renderInstance.lastFrame()!)).not.toContain('[001]') + + await sendInputAndWait(renderInstance, 50, '\u001B[A') + expect(unstyled(renderInstance.lastFrame()!)).toContain('[001]') + renderInstance.unmount() + }) + + test('resizes the layout to fill the terminal', async () => { + const renderInstance = render( + , + ) 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.stdout.columns = 60 + renderInstance.stdout.rows = 40 + renderInstance.stdout.emit('resize') + await sendInputAndWait(renderInstance, 20) + const resizedLines = unstyled(renderInstance.lastFrame()!).split('\n') + expect(resizedLines).toHaveLength(40) + expect(resizedLines.find((line) => line.startsWith('╭'))).toHaveLength(60) renderInstance.unmount() }) @@ -693,11 +961,12 @@ describe('DevSessionUI', () => { expect(output).not.toContain('Preview URL:') expect(output).not.toContain('Dev Console URL:') expect(output).not.toContain('GraphiQL URL:') + expect(renderInstance.lastFrame()).toContain('https://shopify.dev') renderInstance.unmount() }) - test('shows URL list when terminal does not support hyperlinks', async () => { + test('shows URLs inline when terminal does not support hyperlinks', async () => { // Given mocks.terminalSupportsHyperlinks.mockReturnValue(false) @@ -713,14 +982,14 @@ describe('DevSessionUI', () => { await waitForInputsToBeReady() - // Then - both shortcuts with label text and URL list should be present + // Then - each shortcut and its URL share one row so the footer stays compact const output = unstyled(renderInstance.lastFrame()!) expect(output).toContain('(p) Open app preview') expect(output).toContain('(c) Open Dev Console for extension previews') expect(output).toContain('(g) Open GraphiQL (Admin API)') - expect(output).toContain('Preview URL: https://shopify.com') - expect(output).toContain('Dev Console URL: https://mystore.myshopify.com/admin?dev-console=show') - expect(output).toContain('GraphiQL URL: https://graphiql.shopify.com') + expect(output).toContain('Open app preview: https://shopify.com') + expect(output).toContain('Open Dev Console for extension previews:') + expect(output).toContain('Open GraphiQL (Admin API): https://graphi') renderInstance.unmount() }) 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 a6de1aee8e5..ca6951d2207 100644 --- a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx +++ b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx @@ -4,13 +4,21 @@ import {DevSessionStatus, DevSessionStatusManager} from '../../processes/dev-ses import {MAX_EXTENSION_HANDLE_LENGTH} from '../../../../models/extensions/schemas.js' import {buildDevConsoleURL} from '../../../../utilities/app/app-url.js' import {OutputProcess} from '@shopify/cli-kit/node/output' -import {Alert, ConcurrentOutput, Link, LoadingIndicator, TabularData} from '@shopify/cli-kit/node/ui/components' +import { + ConcurrentOutput, + type ConcurrentOutputChunk, + Link, + LoadingIndicator, + TabularData, +} from '@shopify/cli-kit/node/ui/components' import {useAbortSignal} from '@shopify/cli-kit/node/ui/hooks' -import React, {FunctionComponent, useEffect, useMemo, useState} from 'react' +import React, {FunctionComponent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react' import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort' -import {Box, MouseProvider, Text, useInput, useStdin} from '@shopify/cli-kit/node/ink' +import {Box, MouseProvider, Text, useInput, useStdin, useStdout} from '@shopify/cli-kit/node/ink' import {handleCtrlC} from '@shopify/cli-kit/node/ui' import {openURL, terminalSupportsHyperlinks} from '@shopify/cli-kit/node/system' +import {getMouseEnabled} from '@shopify/cli-kit/node/mouse' +import {basename} from '@shopify/cli-kit/node/path' import figures from '@shopify/cli-kit/node/figures' import {waitForPostRunHookAndExit} from '@shopify/cli-kit/node/hooks/postrun' import {Writable} from 'stream' @@ -21,6 +29,11 @@ interface DevStatusShortcut extends TabShortcut { url?: string } +// Three rows for the buttons, four for the largest tab, and breathing room between them. +const BOTTOM_PANEL_HEIGHT = 9 +const MOUSE_TEXT_SELECTION_HINT = + 'If you want to select text, try holding Option or Shift while dragging. Or disable mouse support with `shopify config mouse off`.' + const StatusMessage = ({message, type}: NonNullable) => { if (type === 'loading') return @@ -41,7 +54,30 @@ interface DevSesionUIProps { organizationName?: string configPath?: string localURL?: string + usingLocalhost?: boolean + unavailableGraphiqlPort?: number + localhostPortUnavailable?: number onAbort: () => Promise + onOutput?: (chunk: ConcurrentOutputChunk) => void +} + +const FullScreenLayout: FunctionComponent = ({children}) => { + const {stdout} = useStdout() + const [terminalSize, setTerminalSize] = useState({columns: stdout.columns, rows: stdout.rows}) + + useLayoutEffect(() => { + const updateTerminalSize = () => setTerminalSize({columns: stdout.columns, rows: stdout.rows}) + stdout.on('resize', updateTerminalSize) + return () => { + stdout.off('resize', updateTerminalSize) + } + }, [stdout]) + + return ( + + {children} + + ) } const DevSessionUI: FunctionComponent = ({ @@ -54,22 +90,76 @@ const DevSessionUI: FunctionComponent = ({ organizationName, configPath, localURL, + usingLocalhost = false, + unavailableGraphiqlPort, + localhostPortUnavailable, onAbort, + onOutput, }) => { const {isRawModeSupported: canUseShortcuts} = useStdin() + const mouseEnabled = getMouseEnabled() + const processesWithInitialLogs = useMemo(() => { + const initialLogs: string[] = [] + if (configPath) { + initialLogs.push( + `Using ${basename(configPath)} for default values. You can pass \`--reset\` to your command to reset your app configuration.`, + ) + } + if (usingLocalhost) { + initialLogs.push( + '⚠️ `--use-localhost` is not compatible with Shopify features which directly invoke your app (such as Webhooks, App proxy, and Flow actions), or those which require testing your app from another device (such as POS).', + ) + } + if (unavailableGraphiqlPort !== undefined) { + initialLogs.push( + `⚠️ A random port will be used for GraphiQL because ${unavailableGraphiqlPort} is not available. You can choose one with \`--graphiql-port\`.`, + ) + } + if (localhostPortUnavailable !== undefined) { + initialLogs.push( + `⚠️ A random port will be used for localhost because ${localhostPortUnavailable} is not available. You can choose one with \`--localhost-port\` flag.`, + ) + } + if (initialLogs.length === 0) return processes + + const initialLogProcess: OutputProcess = { + prefix: 'app-preview', + action: async (stdout) => { + stdout.write(initialLogs.join('\n')) + }, + } + return [initialLogProcess, ...processes] + }, [configPath, localhostPortUnavailable, processes, unavailableGraphiqlPort, usingLocalhost]) const [isShuttingDownMessage, setIsShuttingDownMessage] = useState(undefined) - const [error, setError] = useState(undefined) const [status, setStatus] = useState(devSessionStatusManager.status) - const [shouldShowPersistentDevInfo, setShouldShowPersistentDevInfo] = useState(false) + const [availableLogPrefixes, setAvailableLogPrefixes] = useState(() => [ + ...new Set(processesWithInitialLogs.map(({prefix}) => prefix)), + ]) + const availableLogPrefixesRef = useRef(new Set(availableLogPrefixes)) + const [selectedLogPrefix, setSelectedLogPrefix] = useState() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const {isAborted} = useAbortSignal(abortController.signal, async (err: any) => { - if (err) setError(typeof err === 'string' ? err : err.message) - const appPreviewReady = devSessionStatusManager.status.isReady - if (appPreviewReady) { - setShouldShowPersistentDevInfo(true) - } else { + const addAvailableLogPrefix = useCallback((prefix: string) => { + if (availableLogPrefixesRef.current.has(prefix)) return + + availableLogPrefixesRef.current.add(prefix) + setAvailableLogPrefixes((currentPrefixes) => [...currentPrefixes, prefix]) + }, []) + + const filterOutputByPrefix = useCallback( + (prefix: string) => selectedLogPrefix === undefined || prefix === selectedLogPrefix, + [selectedLogPrefix], + ) + + const selectNextLogPrefix = () => { + setSelectedLogPrefix((currentPrefix) => { + const currentPrefixIndex = currentPrefix === undefined ? -1 : availableLogPrefixes.indexOf(currentPrefix) + return availableLogPrefixes[currentPrefixIndex + 1] + }) + } + + const {isAborted} = useAbortSignal(abortController.signal, async () => { + if (!devSessionStatusManager.status.isReady) { setIsShuttingDownMessage('Shutting down dev ...') await onAbort() } @@ -77,7 +167,7 @@ const DevSessionUI: FunctionComponent = ({ }) const errorHandledProcesses = useMemo(() => { - return processes.map((process) => { + return processesWithInitialLogs.map((process) => { return { ...process, action: async (stdout: Writable, stderr: Writable, signal: AbortSignal) => { @@ -90,7 +180,7 @@ const DevSessionUI: FunctionComponent = ({ }, } }) - }, [processes, abortController]) + }, [processesWithInitialLogs, abortController]) // Subscribe to dev session status updates useEffect(() => { @@ -101,6 +191,10 @@ const DevSessionUI: FunctionComponent = ({ } }, []) + useEffect(() => { + processesWithInitialLogs.forEach(({prefix}) => addAvailableLogPrefix(prefix)) + }, [addAvailableLogPrefix, processesWithInitialLogs]) + useInput( (input, key) => { handleCtrlC(input, key, () => abortController.abort()) @@ -167,33 +261,41 @@ const DevSessionUI: FunctionComponent = ({ )} {canUseShortcuts && activeShortcuts.length > 0 && ( - + {activeShortcuts.map((shortcut) => ( - + {figures.pointerSmall} ({shortcut.key}){' '} {terminalSupportsHyperlinks() && shortcut.url ? ( ) : ( - shortcut.shortcutLabel + <> + {shortcut.shortcutLabel} + {shortcut.url ? ( + <> + : + + ) : null} + )} ))} )} - + {isShuttingDownMessage ? ( {isShuttingDownMessage} ) : ( <> {status.isReady && !(canUseShortcuts && terminalSupportsHyperlinks()) && ( <> - {activeShortcuts - .filter((shortcut) => shortcut.url) - .map((shortcut) => ( - - {shortcut.linkLabel} URL: - - ))} + {!canUseShortcuts && + activeShortcuts + .filter((shortcut) => shortcut.url) + .map((shortcut) => ( + + {shortcut.linkLabel} URL: + + ))} )} @@ -234,6 +336,13 @@ const DevSessionUI: FunctionComponent = ({ ), }, + // eslint-disable-next-line id-length + f: { + label: `Filter logs: ${selectedLogPrefix ?? 'all'}`, + action: async () => { + selectNextLogPrefix() + }, + }, q: { label: 'Quit', action: async () => { @@ -249,24 +358,23 @@ const DevSessionUI: FunctionComponent = ({ prefixColumnSize={MAX_EXTENSION_HANDLE_LENGTH} abortSignal={abortController.signal} keepRunningAfterProcessesResolve={true} + scrollable={canUseShortcuts} useAlternativeColorPalette={true} + outputFilter={canUseShortcuts ? filterOutputByPrefix : undefined} + onOutputPrefix={canUseShortcuts ? addAvailableLogPrefix : undefined} + onOutput={onOutput} + mouseInteractionHint={ + mouseEnabled + ? { + prefix: 'app-preview', + message: MOUSE_TEXT_SELECTION_HINT, + } + : undefined + } /> - {shouldShowPersistentDevInfo && ( - - - - )} {/* eslint-disable-next-line no-negated-condition */} {!isAborted ? ( - + {canUseShortcuts ? ( ) : ( @@ -287,17 +395,13 @@ const DevSessionUI: FunctionComponent = ({ )} ) : null} - {error ? ( - - {error} - - ) : null} ) - return canUseShortcuts && !isAborted ? ( - - {content} + // Even wheel-only mouse reporting prevents native terminal text selection. + return canUseShortcuts ? ( + + {content} ) : ( content diff --git a/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx b/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx index 91b604b2694..bf13c76d715 100644 --- a/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx +++ b/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx @@ -25,7 +25,7 @@ const mocks = vi.hoisted(() => { useStdout: vi.fn(() => { return { stdout: { - columns: 120, + columns: 100, on: vi.fn(), off: vi.fn(), }, @@ -73,20 +73,63 @@ describe('TabPanel', () => { }, } - test('renders tab headers with line separators', async () => { + test('renders tab headers as buttons', async () => { const renderInstance = render() await waitForInputsToBeReady() const output = unstyled(renderInstance.lastFrame()!) - expect(output).toContain('────────────────') + expect(output).toContain('╭') + expect(output).toContain('╰') + expect(output.split('\n').some((line) => /^─+$/.test(line))).toBe(false) expect(output).toContain('(a) First Tab') expect(output).toContain('(b) Second Tab') expect(output).toContain('(c) Action Tab') + expect(output).toContain('S> Shopify CLI') renderInstance.unmount() }) + test('renders actions together on the right', async () => { + const renderInstance = render() + + await waitForInputsToBeReady() + + const header = unstyled(renderInstance.lastFrame()!) + .split('\n') + .find((line) => line.includes('(c) Action Tab'))! + expect(header.indexOf('(b) Second Tab')).toBeLessThan(header.indexOf('(c) Action Tab')) + + renderInstance.unmount() + }) + + test('continues the panel border with a single line after the content tabs', async () => { + const originalUseStdoutImplementation = mocks.useStdout.getMockImplementation()! + mocks.useStdout.mockImplementation(() => { + return { + stdout: { + columns: 200, + on: vi.fn(), + off: vi.fn(), + }, + } + }) + const renderInstance = render() + + await waitForInputsToBeReady() + + const outputLines = unstyled(renderInstance.lastFrame()!).split('\n') + const tabLabelRow = outputLines.findIndex((line) => line.includes('(b) Second Tab')) + const secondTabLabelColumn = outputLines[tabLabelRow]!.indexOf('(b) Second Tab') + const fillerStartColumn = secondTabLabelColumn + '(b) Second Tab'.length + 2 + const panelRightColumn = outputLines[tabLabelRow + 1]!.indexOf('╮') + expect(outputLines[tabLabelRow + 1]!.slice(fillerStartColumn, panelRightColumn)).toMatch(/^─+$/) + expect(outputLines[tabLabelRow - 1]?.at(panelRightColumn - 1)).toBe(' ') + + renderInstance.unmount() + mocks.useStdout.mockImplementation(originalUseStdoutImplementation) + }) + test('shows initial active tab content', async () => { const renderInstance = render() @@ -120,8 +163,11 @@ describe('TabPanel', () => { const renderInstance = render() await waitForInputsToBeReady() + const outputLines = unstyled(renderInstance.lastFrame()!).split('\n') + const tabRow = outputLines.findIndex((line) => line.includes('(b) Second Tab')) + const tabColumn = outputLines[tabRow]!.indexOf('(b) Second Tab') await waitForContent(renderInstance, 'Second tab content', () => - mouseClick(20, 2).forEach((input) => renderInstance.stdin.write(input)), + mouseClick(tabColumn + 1, tabRow + 1).forEach((input) => renderInstance.stdin.write(input)), ) expect(renderInstance.lastFrame()).toContain('Second tab content') @@ -134,12 +180,16 @@ describe('TabPanel', () => { const renderInstance = render() 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') + const outputLines = unstyled(renderInstance.lastFrame()!).split('\n') + const tabRow = outputLines.findIndex((line) => line.includes('(b) Second Tab')) + const tabColumn = outputLines[tabRow]!.indexOf('(b) Second Tab') + const verticalOffset = 40 - outputLines.length - 1 + // The cursor position listener is registered asynchronously, so retry the response and click together. + await vi.waitFor(() => { + renderInstance.stdin.write('\u001B[40;1R') + mouseClick(tabColumn + 1, verticalOffset + tabRow + 1).forEach((input) => renderInstance.stdin.write(input)) + expect(renderInstance.lastFrame()).toContain('Second tab content') + }) renderInstance.unmount() }) @@ -157,6 +207,19 @@ describe('TabPanel', () => { renderInstance.unmount() }) + test('executes tab action when action tab is clicked', async () => { + const renderInstance = render() + + await waitForInputsToBeReady() + const outputLines = unstyled(renderInstance.lastFrame()!).split('\n') + const actionRow = outputLines.findIndex((line) => line.includes('(c) Action Tab')) + const actionColumn = outputLines[actionRow]!.indexOf('(c) Action Tab') + await sendInputAndWait(renderInstance, 10, ...mouseClick(actionColumn + 1, actionRow + 1)) + + expect(mockAction).toHaveBeenCalledOnce() + renderInstance.unmount() + }) + test('executes shortcut action when shortcut key is pressed', async () => { const renderInstance = render() diff --git a/packages/app/src/cli/services/dev/ui/components/TabPanel.tsx b/packages/app/src/cli/services/dev/ui/components/TabPanel.tsx index a03f694152b..311f07c9797 100644 --- a/packages/app/src/cli/services/dev/ui/components/TabPanel.tsx +++ b/packages/app/src/cli/services/dev/ui/components/TabPanel.tsx @@ -9,6 +9,8 @@ import { useOnClick, type DOMElement, } from '@shopify/cli-kit/node/ink' +import {Link} from '@shopify/cli-kit/node/ui/components' +import {terminalSupportsHyperlinks} from '@shopify/cli-kit/node/system' export interface Tab { label: string @@ -33,26 +35,72 @@ interface TabPanelProps { initialActiveTab: string } -// Using a width less than 100% reduces (but doesn't eliminate) screen artifacts when resizing the terminal -const TAB_WIDTH_PERCENTAGE = 0.9 - +const TAB_WIDTH_PERCENTAGE = 1 +const INFORMATION_PANEL_WIDTH_PERCENTAGE = 0.5 +const SHOPIFY_GREEN = '#96BF48' +const SHOPIFY_CLI_DOCUMENTATION_URL = 'https://shopify.dev/docs/apps/build/cli-for-apps' +const CHROME_TAB_BORDER = { + topLeft: '╭', + top: '─', + topRight: '╮', + right: '│', + bottomRight: '┴', + bottom: '─', + bottomLeft: '┴', + left: '│', +} +const FIRST_CHROME_TAB_BORDER = { + ...CHROME_TAB_BORDER, + bottomLeft: '├', +} +const ACTIVE_CHROME_TAB_BORDER = { + ...CHROME_TAB_BORDER, + bottomRight: '└', + bottom: ' ', + bottomLeft: '┘', +} +const FIRST_ACTIVE_CHROME_TAB_BORDER = { + ...ACTIVE_CHROME_TAB_BORDER, + bottomLeft: '│', +} +const CONTENT_PANEL_BORDER = { + topLeft: '├', + top: '─', + topRight: '┤', + right: '│', + bottomRight: '╯', + bottom: '─', + bottomLeft: '╰', + left: '│', +} interface ClickableTabProps { active?: boolean + chromeTab?: boolean + firstChromeTab?: boolean header: string + marginRight?: number onClick: () => void } -const ClickableTab: React.FunctionComponent = ({active = false, header, onClick}) => { +const ClickableTab: React.FunctionComponent = ({ + active = false, + chromeTab = false, + firstChromeTab = false, + header, + marginRight = 1, + onClick, +}) => { const tabRef = useRef(null) + let chromeTabBorder = firstChromeTab ? FIRST_CHROME_TAB_BORDER : CHROME_TAB_BORDER + if (active) chromeTabBorder = ACTIVE_CHROME_TAB_BORDER + if (active && firstChromeTab) chromeTabBorder = FIRST_ACTIVE_CHROME_TAB_BORDER useOnClick(tabRef, (event) => { if (event.button === 'left') onClick() }) return ( - - - {header} - + + {header} ) } @@ -135,12 +183,25 @@ export const TabPanel: React.FunctionComponent = ({tabs, initialA return { ...tab, inputKey: key, - header: ` (${key}) ${tab.label} `, + header: `(${key}) ${tab.label}`, } }) const contentTabs = tabsArray.filter((tab) => !tab.action) const actionTabs = tabsArray.filter((tab) => tab.action) + const requiredContentPanelWidth = contentTabs.reduce((width, tab) => width + tab.header.length + 4, 0) + const requiredActionPanelWidth = actionTabs.reduce( + (width, tab, index) => width + tab.header.length + 4 + (index === 0 ? 0 : 1), + 0, + ) + const informationPanelWidth = Math.max( + 1, + Math.min( + Math.max(Math.floor(tabWidth * INFORMATION_PANEL_WIDTH_PERCENTAGE), requiredContentPanelWidth), + tabWidth - requiredActionPanelWidth - 1, + ), + ) + const actionPanelWidth = tabWidth - informationPanelWidth - 1 const activateTab = async (tab: TabDisplay) => { if (tab.action) { @@ -156,46 +217,82 @@ export const TabPanel: React.FunctionComponent = ({tabs, initialA } return ( - <> + + + + {contentTabs.map((tab, index) => ( + activateTabFromClick(tab)} + /> + ))} + + + + + + + {tabs[activeTab]?.content} + + + - - - {contentTabs.map((tab) => ( - + {displayActions && ( + + {actionTabs.map((tab, index) => ( activateTabFromClick(tab)} /> - - - ))} - - {displayActions && ( - - {actionTabs.map((tab, index) => ( - - activateTabFromClick(tab)} /> - {index < actionTabs.length - 1 && } - ))} )} + + + + S + + + > + {' '} + {terminalSupportsHyperlinks() ? ( + + ) : ( + 'Shopify CLI' + )} + + - {/* Tab Content Area */} - - {tabs[activeTab]?.content} - - + ) } diff --git a/packages/app/src/cli/services/dev/ui/dev-preview-info.ts b/packages/app/src/cli/services/dev/ui/dev-preview-info.ts new file mode 100644 index 00000000000..7ac88839ab4 --- /dev/null +++ b/packages/app/src/cli/services/dev/ui/dev-preview-info.ts @@ -0,0 +1,12 @@ +import {type RenderAlertOptions} from '@shopify/cli-kit/node/ui' + +export function devPreviewInfo(shopFqdn: string): RenderAlertOptions { + return { + headline: `A preview of your development changes is still available on ${shopFqdn}.`, + body: ['Run', {command: 'shopify app dev clean'}, 'to restore the latest released version of your app.'], + link: { + label: 'Learn more about dev previews', + url: 'https://shopify.dev/beta/developer-dashboard/shopify-app-dev', + }, + } +} diff --git a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx index d0978a81a0b..ae7286c120c 100644 --- a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.test.tsx @@ -1,10 +1,18 @@ import {ConcurrentOutput, useConcurrentOutputContext} from './ConcurrentOutput.js' -import {render, waitForContent} from '../../testing/ui.js' +import {MouseProvider} from './Mouse.js' +import { + render, + sendInputAndWait, + sendInputAndWaitForChange, + sendInputAndWaitForContent, + waitForContent, +} from '../../testing/ui.js' import {AbortController, AbortSignal} from '../../../../public/node/abort.js' import {unstyled} from '../../../../public/node/output.js' +import {Box} from 'ink' import React from 'react' -import {describe, expect, test} from 'vitest' +import {describe, expect, test, vi} from 'vitest' import {Writable} from 'stream' @@ -23,6 +31,18 @@ class Synchronizer { } } +function mouseClick(column: number, row: number): [string, string] { + return [`\u001B[<0;${column};${row}M`, `\u001B[<0;${column};${row}m`] +} + +function mouseDrag(startColumn: number, startRow: number, endColumn: number, endRow: number): [string, string, string] { + return [ + `\u001B[<0;${startColumn};${startRow}M`, + `\u001B[<32;${endColumn};${endRow}M`, + `\u001B[<0;${endColumn};${endRow}m`, + ] +} + describe('ConcurrentOutput', () => { test('renders a stream of concurrent outputs from sub-processes', async () => { // Given @@ -108,6 +128,40 @@ describe('ConcurrentOutput', () => { gate.resolve() }) + test('reports every output chunk after normalizing it for display', async () => { + const outputSync = new Synchronizer() + const gate = new Synchronizer() + const onOutput = vi.fn() + const process = { + prefix: 'backend', + action: async (stdout: Writable, stderr: Writable) => { + stdout.write('\u001b[32mfirst line\nsecond line\u001b[39m\n') + useConcurrentOutputContext({outputPrefix: 'app-extension'}, () => stderr.write('third line')) + outputSync.resolve() + await gate.promise + }, + } + + const renderInstance = render( + , + ) + await outputSync.promise + await waitForContent(renderInstance, 'third line') + + expect(onOutput).toHaveBeenNthCalledWith(1, { + lines: ['first line', 'second line'], + prefix: 'backend', + timestamp: expect.stringMatching(/^\d{2}:\d{2}:\d{2}$/), + }) + expect(onOutput).toHaveBeenNthCalledWith(2, { + lines: ['third line'], + prefix: 'app-extension', + timestamp: expect.stringMatching(/^\d{2}:\d{2}:\d{2}$/), + }) + + gate.resolve() + }) + test('does not strip ansi codes from the output when stripAnsi is false', async () => { const output = '\u001b[32mfoo\u001b[39m' @@ -177,6 +231,214 @@ describe('ConcurrentOutput', () => { gate.resolve() }) + test('filters matching history and future output without restarting processes', async () => { + const outputSync = new Synchronizer() + const gate = new Synchronizer() + const abortSignal = new AbortController().signal + const observedPrefixes: string[] = [] + let writeBackend = (_message: string) => {} + let writeFrontend = (_message: string) => {} + const backendAction = vi.fn(async (stdout: Writable) => { + writeBackend = (message) => stdout.write(message) + writeBackend('backend message') + await gate.promise + }) + const frontendAction = vi.fn(async (stdout: Writable) => { + writeFrontend = (message) => + useConcurrentOutputContext({outputPrefix: 'custom-frontend'}, () => stdout.write(message)) + writeFrontend('frontend message') + outputSync.resolve() + await gate.promise + }) + const processes = [ + {prefix: 'backend', action: backendAction}, + {prefix: 'frontend', action: frontendAction}, + ] + + const renderInstance = render( + true} + onOutputPrefix={(prefix) => observedPrefixes.push(prefix)} + />, + ) + await outputSync.promise + await waitForContent(renderInstance, 'frontend message') + writeFrontend('second frontend message') + writeBackend('second backend message') + await waitForContent(renderInstance, 'second backend message') + + renderInstance.rerender( + prefix === 'backend'} + onOutputPrefix={(prefix) => observedPrefixes.push(prefix)} + />, + ) + await waitForContent(renderInstance, 'backend message') + + writeFrontend('filtered frontend message') + writeBackend('filtered backend message') + await waitForContent(renderInstance, 'filtered backend message') + + const output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('backend message') + expect(output).toContain('filtered backend message') + expect(output).not.toContain('frontend message') + expect(output).not.toContain('filtered frontend message') + expect(observedPrefixes).toEqual([ + 'backend', + 'custom-frontend', + 'custom-frontend', + 'backend', + 'custom-frontend', + 'backend', + ]) + expect(backendAction).toHaveBeenCalledOnce() + expect(frontendAction).toHaveBeenCalledOnce() + + gate.resolve() + }) + + test('renders output in a bounded viewport and scrolls with arrow and page keys', async () => { + const outputSync = new Synchronizer() + const gate = new Synchronizer() + const process = { + prefix: 'backend', + action: async (stdout: Writable) => { + stdout.write(Array.from({length: 10}, (_, index) => `message ${index + 1}`).join('\n')) + outputSync.resolve() + await gate.promise + }, + } + + const renderInstance = render( + + + + + , + ) + await outputSync.promise + await waitForContent(renderInstance, 'message 10') + + let output = unstyled(renderInstance.lastFrame()!) + expect(output).not.toContain('message 1\n') + expect(output).toContain('message 10') + + await sendInputAndWaitForChange(renderInstance, '\u001B[A') + output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('message 6') + expect(output).not.toContain('message 10') + + await sendInputAndWaitForChange(renderInstance, '\u001B[B') + output = unstyled(renderInstance.lastFrame()!) + expect(output).not.toContain('message 6') + expect(output).toContain('message 10') + + await sendInputAndWaitForChange(renderInstance, '\u001B[5~') + output = unstyled(renderInstance.lastFrame()!) + expect(output).toContain('message 3') + expect(output).not.toContain('message 10') + + await sendInputAndWaitForChange(renderInstance, '\u001B[6~') + output = unstyled(renderInstance.lastFrame()!) + expect(output).not.toContain('message 3') + expect(output).toContain('message 10') + + gate.resolve() + }) + + test('wraps long output and leaves timestamp and prefix columns blank on continuation rows', async () => { + const outputSync = new Synchronizer() + const gate = new Synchronizer() + const process = { + prefix: 'backend', + action: async (stdout: Writable) => { + useConcurrentOutputContext({stripAnsi: false}, () => { + stdout.write( + 'A long log message that wraps onto another row and includes \u001B[32mCONTINUATION_END\u001B[39m', + ) + }) + outputSync.resolve() + await gate.promise + }, + } + + const renderInstance = render( + + + + + , + ) + await outputSync.promise + await waitForContent(renderInstance, 'CONTINUATION_END') + + const output = unstyled(renderInstance.lastFrame()!.replace(/\d/g, '0')) + expect(output).toContain('CONTINUATION_END') + expect(renderInstance.lastFrame()).toContain('\u001B[32mCONTINUATION_END\u001B[39m') + expect(output.split('\n').find((line) => line.includes('CONTINUATION_END'))).toMatch(/^│ {9}│ {9}│ /u) + + await sendInputAndWaitForContent(renderInstance, 'A long log message', '\u001B[5~') + expect(unstyled(renderInstance.lastFrame()!)).toContain('A long log message') + expect(unstyled(renderInstance.lastFrame()!)).not.toContain('CONTINUATION_END') + + gate.resolve() + }) + + test('adds a mouse interaction hint to scrollable output only once', async () => { + const outputSync = new Synchronizer() + const gate = new Synchronizer() + const onOutput = vi.fn() + const hint = 'Hold Option or Shift while dragging to select text.' + const process = { + prefix: 'backend', + action: async (stdout: Writable) => { + stdout.write('backend output') + outputSync.resolve() + await gate.promise + }, + } + const renderInstance = render( + + + + + , + {stdoutIsTTY: true}, + ) + await outputSync.promise + await waitForContent(renderInstance, 'backend output') + + await sendInputAndWait(renderInstance, 20, ...mouseDrag(2, 2, 10, 2)) + await waitForContent(renderInstance, hint) + await sendInputAndWait(renderInstance, 20, ...mouseClick(2, 2)) + + expect(unstyled(renderInstance.lastFrame()!).match(/Hold Option or Shift/g)).toHaveLength(1) + expect(onOutput).toHaveBeenNthCalledWith(2, { + lines: [hint], + prefix: 'app-preview', + timestamp: expect.stringMatching(/^\d{2}:\d{2}:\d{2}$/), + }) + expect(onOutput).toHaveBeenCalledTimes(2) + + gate.resolve() + }) + test('renders prefix column width based on prefixColumnSize', async () => { // Given const processSync1 = new Synchronizer() diff --git a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx index b866189e5ae..c92e8210fc1 100644 --- a/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx +++ b/packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx @@ -1,8 +1,10 @@ +import {useOnPress, useOnWheel} from './Mouse.js' +import {Scrollbar} from './Scrollbar.js' import {OutputProcess} from '../../../../public/node/output.js' import {AbortSignal} from '../../../../public/node/abort.js' import {useComplete} from '../../ui.js' -import React, {FunctionComponent, useCallback, useEffect, useMemo, useState} from 'react' -import {Box, Static, Text, TextProps} from 'ink' +import React, {FunctionComponent, useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {Box, DOMElement, measureElement, Static, Text, TextProps, useInput} from 'ink' import figures from 'figures' import stripAnsi from 'strip-ansi' @@ -16,12 +18,41 @@ export interface ConcurrentOutputProps { showTimestamps?: boolean keepRunningAfterProcessesResolve?: boolean useAlternativeColorPalette?: boolean + /** Renders output in a bounded viewport with keyboard and mouse-wheel scrolling. */ + scrollable?: boolean + /** Filters both existing and future output by its displayed prefix. */ + outputFilter?: (prefix: string) => boolean + /** Called when output is received, including output with a contextual prefix. */ + onOutputPrefix?: (prefix: string) => void + /** Called with every output chunk after ANSI control sequences are normalized for display. */ + onOutput?: (chunk: ConcurrentOutputChunk) => void + /** Adds this output once after the first click or drag inside the scrollable output viewport. */ + mouseInteractionHint?: { + message: string + prefix: string + } +} + +export interface ConcurrentOutputChunk { + lines: string[] + prefix: string + timestamp: string } interface Chunk { color: TextProps['color'] prefix: string lines: string[] + timestamp: string +} + +interface OutputLine { + chunk: Chunk + line: string +} + +interface OutputRow extends OutputLine { + isContinuation: boolean } function addLeadingZero(number: number) { @@ -40,12 +71,287 @@ function currentTime() { return `${hours}:${minutes}:${seconds}` } +function addPrefix(prefix: string, prefixes: string[]) { + const index = prefixes.indexOf(prefix) + if (index !== -1) return index + + prefixes.push(prefix) + return prefixes.length - 1 +} + interface ConcurrentOutputContext { outputPrefix?: string stripAnsi?: boolean } const outputContextStore = new AsyncLocalStorage() +const LOG_SCROLL_STEP = 3 +const VIEWPORT_BORDER_WIDTH = 2 +const SCROLLBAR_WIDTH = 1 +const COLUMN_SEPARATOR_WIDTH = 3 +const TIMESTAMP_WIDTH = '00:00:00'.length +const ESCAPE_CHARACTER = String.fromCharCode(27) +const BELL_CHARACTER = String.fromCharCode(7) +const ANSI_SEQUENCE_PATTERN = new RegExp( + `${ESCAPE_CHARACTER}(?:\\][^${BELL_CHARACTER}]*(?:${BELL_CHARACTER}|${ESCAPE_CHARACTER}\\\\)|\\[[0-?]*[ -/]*[@-~])`, + 'gu', +) +const SGR_SEQUENCE_PATTERN = new RegExp(`^${ESCAPE_CHARACTER}\\[[0-?]*m$`, 'u') +const EMOJI_PATTERN = /\p{Extended_Pictographic}|\p{Regional_Indicator}|\u20E3/u + +interface SegmenterConstructor { + new ( + locales?: string | string[], + options?: {granularity: 'grapheme'}, + ): { + segment: (input: string) => Iterable<{segment: string}> + } +} + +// The supported Node versions provide Intl.Segmenter, but the project's current TypeScript lib does not declare it. +const Segmenter = (Intl as unknown as {Segmenter: SegmenterConstructor}).Segmenter +const graphemeSegmenter = new Segmenter(undefined, {granularity: 'grapheme'}) + +interface TerminalToken { + isAnsi: boolean + value: string + width: number +} + +function isFullwidthCodePoint(codePoint: number): boolean { + return ( + codePoint >= 0x1100 && + (codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0x303e) || + (codePoint >= 0x3040 && codePoint <= 0xa4cf) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1b000 && codePoint <= 0x1b2ff) || + (codePoint >= 0x1f200 && codePoint <= 0x1f251) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd)) + ) +} + +function graphemeWidth(grapheme: string): number { + const codePoint = grapheme.codePointAt(0) + if (codePoint === undefined || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) return 0 + if (EMOJI_PATTERN.test(grapheme) || isFullwidthCodePoint(codePoint)) return 2 + return 1 +} + +function terminalTokens(input: string): TerminalToken[] { + const tokens: TerminalToken[] = [] + let plainTextStart = 0 + + const appendPlainText = (plainText: string) => { + for (const {segment} of graphemeSegmenter.segment(plainText)) { + tokens.push({isAnsi: false, value: segment, width: graphemeWidth(segment)}) + } + } + + for (const match of input.matchAll(ANSI_SEQUENCE_PATTERN)) { + appendPlainText(input.slice(plainTextStart, match.index)) + tokens.push({isAnsi: true, value: match[0], width: 0}) + plainTextStart = match.index + match[0].length + } + appendPlainText(input.slice(plainTextStart)) + + return tokens +} + +function wrapTerminalLine(line: string, maximumWidth: number): string[] { + const width = Math.max(1, maximumWidth) + const rows: string[] = [] + let activeSgrSequences = '' + let currentRow = '' + let currentWidth = 0 + + for (const token of terminalTokens(line)) { + if (token.isAnsi) { + currentRow += token.value + if (SGR_SEQUENCE_PATTERN.test(token.value)) activeSgrSequences += token.value + continue + } + + if (currentWidth > 0 && currentWidth + token.width > width) { + rows.push(activeSgrSequences ? `${currentRow}\u001B[0m` : currentRow) + currentRow = activeSgrSequences + currentWidth = 0 + } + + currentRow += token.value + currentWidth += token.width + } + + rows.push(currentRow) + return rows +} + +function wrapOutputLines(outputLines: OutputLine[], messageWidth: number): OutputRow[] { + return outputLines.flatMap(({chunk, line}) => + wrapTerminalLine(line, messageWidth).map((wrappedLine, index) => ({ + chunk, + line: wrappedLine, + isContinuation: index > 0, + })), + ) +} + +interface ScrollableConcurrentOutputProps { + chunks: Chunk[] + formatPrefix: (prefix: string) => string + lineVertical: string + outputFilter?: (prefix: string) => boolean + prefixColumnSize: number + showTimestamps: boolean + onMouseInteraction?: () => void +} + +const ScrollableConcurrentOutput: FunctionComponent = ({ + chunks, + formatPrefix, + lineVertical, + outputFilter, + prefixColumnSize, + showTimestamps, + onMouseInteraction, +}) => { + const viewportRef = useRef(null) + const previousOutputFilterRef = useRef(outputFilter) + const [viewportDimensions, setViewportDimensions] = useState<{height: number; width: number}>() + const [scrollOffset, setScrollOffset] = useState(0) + const [isFollowingOutput, setIsFollowingOutput] = useState(true) + const outputLines = useMemo( + () => + chunks.flatMap((chunk) => + !outputFilter || outputFilter(chunk.prefix) ? chunk.lines.map((line) => ({chunk, line})) : [], + ), + [chunks, outputFilter], + ) + const visibleLineCount = Math.max(1, (viewportDimensions?.height ?? 1) - VIEWPORT_BORDER_WIDTH) + const metadataWidth = + prefixColumnSize + COLUMN_SEPARATOR_WIDTH + (showTimestamps ? TIMESTAMP_WIDTH + COLUMN_SEPARATOR_WIDTH : 0) + const rowsWithoutScrollbar = useMemo( + () => + viewportDimensions + ? wrapOutputLines(outputLines, viewportDimensions.width - VIEWPORT_BORDER_WIDTH - metadataWidth) + : outputLines.map(({chunk, line}) => ({chunk, line, isContinuation: false})), + [metadataWidth, outputLines, viewportDimensions], + ) + const outputRows = useMemo( + () => + viewportDimensions && rowsWithoutScrollbar.length > visibleLineCount + ? wrapOutputLines( + outputLines, + viewportDimensions.width - VIEWPORT_BORDER_WIDTH - metadataWidth - SCROLLBAR_WIDTH, + ) + : rowsWithoutScrollbar, + [metadataWidth, outputLines, rowsWithoutScrollbar, viewportDimensions, visibleLineCount], + ) + const maximumScrollOffset = Math.max(0, outputRows.length - visibleLineCount) + + const updateViewportDimensions = useCallback((node: DOMElement | null) => { + viewportRef.current = node + if (!node) return + const measuredDimensions = measureElement(node) + setViewportDimensions((currentDimensions) => + currentDimensions?.height === measuredDimensions.height && currentDimensions.width === measuredDimensions.width + ? currentDimensions + : measuredDimensions, + ) + }, []) + + const scrollBy = useCallback( + (lineCount: number) => { + setScrollOffset((currentOffset) => { + const nextOffset = Math.max(0, Math.min(maximumScrollOffset, currentOffset + lineCount)) + setIsFollowingOutput(nextOffset === maximumScrollOffset) + return nextOffset + }) + }, + [maximumScrollOffset], + ) + + useOnWheel(viewportRef, (event) => { + if (event.button === 'wheel-up') scrollBy(-LOG_SCROLL_STEP) + if (event.button === 'wheel-down') scrollBy(LOG_SCROLL_STEP) + }) + // A drag that attempts to select text does not produce a click, but it always begins + // with a press. Handling the press covers both clicks and drags in click-only mouse mode. + useOnPress(viewportRef, onMouseInteraction) + + useInput( + (_input, key) => { + if (key.upArrow) scrollBy(-1) + if (key.downArrow) scrollBy(1) + if (key.pageUp) scrollBy(-visibleLineCount) + if (key.pageDown) scrollBy(visibleLineCount) + }, + {isActive: true}, + ) + + useEffect(() => { + const filterChanged = previousOutputFilterRef.current !== outputFilter + previousOutputFilterRef.current = outputFilter + + if (filterChanged) { + setScrollOffset(0) + setIsFollowingOutput(false) + } else { + setScrollOffset((currentOffset) => + isFollowingOutput ? maximumScrollOffset : Math.min(currentOffset, maximumScrollOffset), + ) + } + }, [isFollowingOutput, maximumScrollOffset, outputFilter]) + + const visibleOutputRows = outputRows.slice(scrollOffset, scrollOffset + visibleLineCount) + + return ( + + + {visibleOutputRows.map(({chunk, line, isContinuation}, index) => ( + + {showTimestamps ? ( + + {isContinuation ? ' '.repeat(chunk.timestamp.length) : chunk.timestamp} {lineVertical}{' '} + + ) : null} + + {isContinuation ? ' '.repeat(prefixColumnSize) : formatPrefix(chunk.prefix)} + + + {' '} + {lineVertical} {line} + + + ))} + + {outputRows.length > visibleLineCount ? ( + + ) : null} + + ) +} function useConcurrentOutputContext(context: ConcurrentOutputContext, callback: () => T): T { return outputContextStore.run(context, callback) @@ -91,9 +397,20 @@ const ConcurrentOutput: FunctionComponent = ({ showTimestamps = true, keepRunningAfterProcessesResolve = false, useAlternativeColorPalette = false, + scrollable = false, + outputFilter, + onOutputPrefix, + onOutput, + mouseInteractionHint, }) => { const [processOutput, setProcessOutput] = useState([]) const [completionResult, setCompletionResult] = useState<{error?: Error} | null>(null) + const onOutputPrefixRef = useRef(onOutputPrefix) + onOutputPrefixRef.current = onOutputPrefix + const onOutputRef = useRef(onOutput) + onOutputRef.current = onOutput + const prefixesRef = useRef([]) + const mouseInteractionHintShownRef = useRef(false) const complete = useComplete() const concurrentColors: TextProps['color'][] = useMemo( () => @@ -115,15 +432,6 @@ const ConcurrentOutput: FunctionComponent = ({ return Math.min(columnSize, maxColumnSize) }, [processes, prefixColumnSize]) - const addPrefix = (prefix: string, prefixes: string[]) => { - const index = prefixes.indexOf(prefix) - if (index !== -1) { - return index - } - prefixes.push(prefix) - return prefixes.length - 1 - } - const lineColor = useCallback( (index: number) => { const colorIndex = index % concurrentColors.length @@ -132,8 +440,25 @@ const ConcurrentOutput: FunctionComponent = ({ [concurrentColors], ) + const appendOutput = useCallback( + (lines: string[], prefix: string) => { + const prefixIndex = addPrefix(prefix, prefixesRef.current) + const outputChunk = { + color: lineColor(prefixIndex), + prefix, + lines, + timestamp: currentTime(), + } + + onOutputPrefixRef.current?.(prefix) + onOutputRef.current?.({lines: outputChunk.lines, prefix, timestamp: outputChunk.timestamp}) + setProcessOutput((previousProcessOutput) => [...previousProcessOutput, outputChunk]) + }, + [lineColor], + ) + const writableStream = useCallback( - (process: OutputProcess, prefixes: string[]) => { + (process: OutputProcess) => { return new Writable({ write(chunk, _encoding, next) { const context = outputContextStore.getStore() @@ -141,42 +466,40 @@ const ConcurrentOutput: FunctionComponent = ({ const shouldStripAnsi = context?.stripAnsi ?? true const log = chunk.toString('utf8').replace(/(\n)$/, '') - const index = addPrefix(prefix, prefixes) - - const lines = shouldStripAnsi ? stripAnsi(log).split(/\n/) : log.split(/\n/) - setProcessOutput((previousProcessOutput) => [ - ...previousProcessOutput, - { - color: lineColor(index), - prefix, - lines, - }, - ]) + appendOutput(shouldStripAnsi ? stripAnsi(log).split(/\n/) : log.split(/\n/), prefix) next() }, }) }, - [lineColor], + [appendOutput], ) - const formatPrefix = (prefix: string) => { - // Truncate prefix if needed - if (prefix.length > calculatedPrefixColumnSize) { - return prefix.substring(0, calculatedPrefixColumnSize) - } + const showMouseInteractionHint = useCallback(() => { + if (!mouseInteractionHint || mouseInteractionHintShownRef.current) return - return `${' '.repeat(calculatedPrefixColumnSize - prefix.length)}${prefix}` - } + mouseInteractionHintShownRef.current = true + appendOutput([mouseInteractionHint.message], mouseInteractionHint.prefix) + }, [appendOutput, mouseInteractionHint]) + + const formatPrefix = useCallback( + (prefix: string) => { + // Truncate prefix if needed + if (prefix.length > calculatedPrefixColumnSize) { + return prefix.substring(0, calculatedPrefixColumnSize) + } + + return `${' '.repeat(calculatedPrefixColumnSize - prefix.length)}${prefix}` + }, + [calculatedPrefixColumnSize], + ) useEffect(() => { const runProcesses = async () => { - const prefixes: string[] = [] - try { await Promise.all( processes.map(async (process) => { - const stdout = writableStream(process, prefixes) - const stderr = writableStream(process, prefixes) + const stdout = writableStream(process) + const stderr = writableStream(process) await process.action(stdout, stderr, abortSignal) }), ) @@ -203,31 +526,46 @@ const ConcurrentOutput: FunctionComponent = ({ const {lineVertical} = figures - return ( - - {(chunk, index) => { - return ( - - {chunk.lines.map((line, index) => ( - - - {showTimestamps ? ( - - {currentTime()} {lineVertical}{' '} - - ) : null} - {formatPrefix(chunk.prefix)} - - {' '} - {lineVertical} {line} - - - - ))} - - ) - }} - + const renderChunk = (chunk: Chunk, index: number) => ( + + {chunk.lines.map((line, index) => ( + + + {showTimestamps ? ( + + {chunk.timestamp} {lineVertical}{' '} + + ) : null} + {formatPrefix(chunk.prefix)} + + {' '} + {lineVertical} {line} + + + + ))} + ) + + if (scrollable) { + return ( + + ) + } + + if (outputFilter) { + // Ink's Static output is immutable once written, so filterable output must remain in the live render tree. + return {processOutput.filter(({prefix}) => outputFilter(prefix)).map(renderChunk)} + } + + return {renderChunk} } export {ConcurrentOutput, ConcurrentOutputContext, useConcurrentOutputContext} diff --git a/packages/cli-kit/src/private/node/ui/components/LoadingBar.test.tsx b/packages/cli-kit/src/private/node/ui/components/LoadingBar.test.tsx index 05fb333d754..1214b3a71dc 100644 --- a/packages/cli-kit/src/private/node/ui/components/LoadingBar.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/LoadingBar.test.tsx @@ -35,7 +35,7 @@ describe('LoadingBar', () => { const {lastFrame, unmount} = renderWithTTY() const frame = lastFrame()! - expect(unstyled(frame)).toBe('S> Loading content ...') + expect(unstyled(frame)).toBe('S> Loading content...') expect(frame).toContain('\u001B[1m') expect(frame).toContain('\u001B[3m') expect(frame).toContain('\u001B[38;2;150;191;72m') @@ -52,7 +52,7 @@ describe('LoadingBar', () => { await vi.advanceTimersByTimeAsync(350) }) - expect(unstyled(lastFrame()!)).toBe('S Uploading theme ...') + expect(unstyled(lastFrame()!)).toBe('S Uploading theme...') } finally { unmount() vi.useRealTimers() @@ -63,7 +63,7 @@ describe('LoadingBar', () => { const {lastFrame, unmount} = renderWithTTY() const frame = lastFrame()! - expect(unstyled(frame)).toBe('S> Processing files ...') + expect(unstyled(frame)).toBe('S> Processing files...') expect(frame).not.toContain('\u001B[38;2;150;191;72m') unmount() @@ -74,7 +74,7 @@ describe('LoadingBar', () => { const {lastFrame, unmount} = renderWithTTY() const frame = lastFrame()! - expect(unstyled(frame)).toBe('S> Downloading packages ...') + expect(unstyled(frame)).toBe('S> Downloading packages...') expect(frame).not.toContain('\u001B[38;2;150;191;72m') unmount() @@ -83,7 +83,7 @@ describe('LoadingBar', () => { test('renders correctly with an empty title', async () => { const {lastFrame, unmount} = renderWithTTY() - expect(unstyled(lastFrame()!)).toBe('S> ...') + expect(unstyled(lastFrame()!)).toBe('S> ...') unmount() }) @@ -91,12 +91,12 @@ describe('LoadingBar', () => { test('hides the loading indicator when noProgressBar is true', async () => { const {lastFrame} = renderWithTTY() - expect(unstyled(lastFrame()!)).toBe('task 1 ...') + expect(unstyled(lastFrame()!)).toBe('task 1...') }) test('shows only static title text when output stream is not a TTY', async () => { const {lastFrame} = render() - expect(unstyled(lastFrame()!)).toBe('Installing dependencies ...') + expect(unstyled(lastFrame()!)).toBe('Installing dependencies...') }) }) diff --git a/packages/cli-kit/src/private/node/ui/components/LoadingBar.tsx b/packages/cli-kit/src/private/node/ui/components/LoadingBar.tsx index 932f9325627..3cbed613871 100644 --- a/packages/cli-kit/src/private/node/ui/components/LoadingBar.tsx +++ b/packages/cli-kit/src/private/node/ui/components/LoadingBar.tsx @@ -22,7 +22,7 @@ const LoadingBar = ({title, noColor, noProgressBar}: React.PropsWithChildren{title} ... + return {title}... } return diff --git a/packages/cli-kit/src/private/node/ui/components/LoadingIndicator.tsx b/packages/cli-kit/src/private/node/ui/components/LoadingIndicator.tsx index a2cfa6cca4a..7a3a7fafdcc 100644 --- a/packages/cli-kit/src/private/node/ui/components/LoadingIndicator.tsx +++ b/packages/cli-kit/src/private/node/ui/components/LoadingIndicator.tsx @@ -31,7 +31,7 @@ const LoadingIndicator = ({title, noColor = !shouldDisplayColors()}: LoadingIndi {isChevronVisible ? '>' : ' '} - {` ${title} ...`} + {` ${title}...`} ) } diff --git a/packages/cli-kit/src/private/node/ui/components/Mouse.test.tsx b/packages/cli-kit/src/private/node/ui/components/Mouse.test.tsx index 13e92321b57..769e3e9fbc9 100644 --- a/packages/cli-kit/src/private/node/ui/components/Mouse.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/Mouse.test.tsx @@ -1,4 +1,4 @@ -import {MouseProvider, useOnClick} from './Mouse.js' +import {MouseProvider, useOnClick, useOnPress} from './Mouse.js' import {render, sendInputAndWait, waitForInputsToBeReady} from '../../testing/ui.js' import React, {useRef} from 'react' import {Box, DOMElement, Text} from 'ink' @@ -23,10 +23,28 @@ function Clickable({onClick}: {onClick: () => void}) { ) } +function Draggable({onPress}: {onPress: () => void}) { + const ref = useRef(null) + useOnPress(ref, onPress) + return ( + + Drag me + + ) +} + function mouseClick(column: number, row: number): [string, string] { return [`\u001B[<0;${column};${row}M`, `\u001B[<0;${column};${row}m`] } +function mouseDrag(startColumn: number, startRow: number, endColumn: number, endRow: number): [string, string, string] { + return [ + `\u001B[<0;${startColumn};${startRow}M`, + `\u001B[<32;${endColumn};${endRow}M`, + `\u001B[<0;${endColumn};${endRow}m`, + ] +} + describe('MouseProvider', () => { beforeEach(() => { mocks.getMouseEnabled.mockReturnValue(true) @@ -48,6 +66,22 @@ describe('MouseProvider', () => { renderInstance.unmount() }) + test('handles the press that begins a drag when mouse interactions are enabled', async () => { + const onPress = vi.fn() + const renderInstance = render( + + + , + {stdoutIsTTY: true}, + ) + + await waitForInputsToBeReady() + await sendInputAndWait(renderInstance, 10, ...mouseDrag(2, 1, 6, 1)) + + expect(onPress).toHaveBeenCalledOnce() + renderInstance.unmount() + }) + test('ignores clicks when mouse interactions are disabled', async () => { mocks.getMouseEnabled.mockReturnValue(false) const onClick = vi.fn() @@ -64,4 +98,29 @@ describe('MouseProvider', () => { expect(onClick).not.toHaveBeenCalled() renderInstance.unmount() }) + + test('stops handling clicks when mouse interactions become inactive', async () => { + const onClick = vi.fn() + const renderInstance = render( + + + , + {stdoutIsTTY: true}, + ) + + await waitForInputsToBeReady() + await sendInputAndWait(renderInstance, 10, ...mouseClick(2, 1)) + expect(onClick).toHaveBeenCalledOnce() + + renderInstance.rerender( + + + , + ) + await waitForInputsToBeReady() + await sendInputAndWait(renderInstance, 10, ...mouseClick(2, 1)) + + expect(onClick).toHaveBeenCalledOnce() + renderInstance.unmount() + }) }) diff --git a/packages/cli-kit/src/private/node/ui/components/Mouse.tsx b/packages/cli-kit/src/private/node/ui/components/Mouse.tsx index b564a192714..56f7dbd1247 100644 --- a/packages/cli-kit/src/private/node/ui/components/Mouse.tsx +++ b/packages/cli-kit/src/private/node/ui/components/Mouse.tsx @@ -7,9 +7,13 @@ import { MouseProvider as InkMouseProvider, useOnClick as useInkOnClick, useOnMouseEnter as useInkOnMouseEnter, + useOnPress as useInkOnPress, + useOnWheel as useInkOnWheel, type ClickHandler, type ElementRef, type MouseEnterHandler, + type MousePressHandler, + type WheelHandler, } from '@ink-tools/ink-mouse' const CURSOR_POSITION_REQUEST = '\u001B[6n' @@ -27,6 +31,8 @@ const MouseOriginContext = createContext(0) interface MouseProviderProps extends React.PropsWithChildren { allowTerminalScrolling?: boolean + isActive?: boolean + mouseEnabled?: boolean trackMouseMovement?: boolean } @@ -102,9 +108,18 @@ export function removeTerminalInputResponses(input: string): string { return removeSgrMouseResponses(sanitizedInput) } -export function MouseProvider({children, ...mouseProviderProps}: MouseProviderProps): React.ReactElement { - if (getMouseEnabled()) { - return {children} +export function MouseProvider({ + children, + isActive = true, + mouseEnabled = getMouseEnabled(), + ...mouseProviderProps +}: MouseProviderProps): React.ReactElement { + if (mouseEnabled) { + return ( + + {children} + + ) } return ( @@ -117,6 +132,7 @@ export function MouseProvider({children, ...mouseProviderProps}: MouseProviderPr function EnabledMouseProvider({ allowTerminalScrolling = false, children, + isActive = true, trackMouseMovement = true, }: MouseProviderProps): React.ReactElement { const rootRef = useRef(null) @@ -125,7 +141,7 @@ function EnabledMouseProvider({ const [verticalOffset, setVerticalOffset] = useState(0) const [rootHeight, setRootHeight] = useState() const scrollReleaseTimeoutRef = useRef>() - const mouseTrackingMode = getMouseTrackingMode(trackMouseMovement) + const mouseTrackingMode = isActive ? getMouseTrackingMode(trackMouseMovement) : undefined useEffect(() => { const measureRoot = () => { @@ -139,7 +155,7 @@ function EnabledMouseProvider({ }, []) useEffect(() => { - if (!stdin.isTTY || !stdout.isTTY || rootHeight === undefined) return + if (!isActive || !stdin.isTTY || !stdout.isTTY || rootHeight === undefined) return const stopListening = () => { clearTimeout(timeout) @@ -160,7 +176,7 @@ function EnabledMouseProvider({ stdout.write(CURSOR_POSITION_REQUEST) return stopListening - }, [rootHeight, stdin, stdout]) + }, [isActive, rootHeight, stdin, stdout]) useEffect(() => { if (mouseTrackingMode && stdout.isTTY) stdout.write(mouseTrackingMode) @@ -196,7 +212,7 @@ function EnabledMouseProvider({ }, [allowTerminalScrolling, mouseTrackingMode, releaseMouseForTerminalScrolling, stdin]) return ( - + {children} @@ -211,11 +227,21 @@ export function useOnClick(ref: ElementRef, handler: ClickHandler | null | undef useInkOnClick(offsetRef, handler) } +export function useOnPress(ref: ElementRef, handler: MousePressHandler | null | undefined): void { + const offsetRef = useOffsetRef(ref) + useInkOnPress(offsetRef, handler) +} + export function useOnMouseEnter(ref: ElementRef, handler: MouseEnterHandler | null | undefined): void { const offsetRef = useOffsetRef(ref) useInkOnMouseEnter(offsetRef, handler) } +export function useOnWheel(ref: ElementRef, handler: WheelHandler | null | undefined): void { + const offsetRef = useOffsetRef(ref) + useInkOnWheel(offsetRef, handler) +} + function useOffsetRef(ref: ElementRef): ElementRef { const verticalOffset = useContext(MouseOriginContext) return useMemo(() => { diff --git a/packages/cli-kit/src/public/node/ink.ts b/packages/cli-kit/src/public/node/ink.ts index b30acb6c9e7..3ec7741322a 100644 --- a/packages/cli-kit/src/public/node/ink.ts +++ b/packages/cli-kit/src/public/node/ink.ts @@ -1,3 +1,3 @@ export {Box, Text, Static, useInput, useStdin, useStdout, measureElement} from 'ink' export type {DOMElement} from 'ink' -export {MouseProvider, useOnClick, useOnMouseEnter} from '../../private/node/ui/components/Mouse.js' +export {MouseProvider, useOnClick, useOnMouseEnter, useOnWheel} from '../../private/node/ui/components/Mouse.js' diff --git a/packages/cli-kit/src/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index 86347f6defe..691205ce6b5 100644 --- a/packages/cli-kit/src/public/node/ui.tsx +++ b/packages/cli-kit/src/public/node/ui.tsx @@ -483,7 +483,7 @@ interface RenderTasksOptions { /** * Runs async tasks and displays their progress to the console. * @example - * Installing dependencies ... + * Installing dependencies... */ export async function renderTasks( @@ -523,7 +523,7 @@ export interface RenderSingleTaskOptions { * @param options.renderOptions - Optional render configuration * @returns The result of the task * @example - * Loading app ... + * Loading app... */ export async function renderSingleTask({ title, diff --git a/packages/cli-kit/src/public/node/ui/components.ts b/packages/cli-kit/src/public/node/ui/components.ts index 7f92c26c91e..3eea8918d0a 100644 --- a/packages/cli-kit/src/public/node/ui/components.ts +++ b/packages/cli-kit/src/public/node/ui/components.ts @@ -1,6 +1,7 @@ export { ConcurrentOutput, ConcurrentOutputContext, + type ConcurrentOutputChunk, useConcurrentOutputContext, } from '../../../private/node/ui/components/ConcurrentOutput.js' export {Alert} from '../../../private/node/ui/components/Alert.js' diff --git a/packages/e2e/setup/cli.ts b/packages/e2e/setup/cli.ts index ad1793d171f..67b33ae574a 100644 --- a/packages/e2e/setup/cli.ts +++ b/packages/e2e/setup/cli.ts @@ -5,6 +5,11 @@ import {execa, type Options as ExecaOptions} from 'execa' import type {E2EEnv} from './env.js' import type * as pty from 'node-pty' +// Full-screen Ink layouts redraw entire frames. Searching the complete PTY history +// after every frame becomes progressively more expensive during long-running commands. +const OUTPUT_SEARCH_WINDOW = 1_000_000 +const RAW_OUTPUT_OVERLAP = 4_096 + export interface ExecResult { stdout: string stderr: string @@ -134,10 +139,27 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({ }) let output = '' + let rawOutputOverlap = '' + let searchableOutputLength = 0 + const searchableOutputChunks: string[] = [] const outputWaiters: {text: string; resolve: () => void; reject: (err: Error) => void}[] = [] + const recentRawOutput = () => output.slice(-OUTPUT_SEARCH_WINDOW) + const searchableOutput = () => searchableOutputChunks.join('') + ptyProcess.onData((data: string) => { output += data + // Include a small overlap so text split across PTY chunks remains + // searchable, then retain only a bounded amount of normalized output. + // This avoids repeatedly stripping the entire full-screen render history. + const normalizedChunk = stripAnsi(`${rawOutputOverlap}${data}`) + searchableOutputChunks.push(normalizedChunk) + searchableOutputLength += normalizedChunk.length + while (searchableOutputLength > OUTPUT_SEARCH_WINDOW) { + const removedChunk = searchableOutputChunks.shift()! + searchableOutputLength -= removedChunk.length + } + rawOutputOverlap = `${rawOutputOverlap}${data}`.slice(-RAW_OUTPUT_OVERLAP) if (process.env.DEBUG === '1') { process.stdout.write(data) } @@ -145,9 +167,9 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({ // Check if any waiters are satisfied (check both raw and stripped // output). resolve() removes the waiter from outputWaiters internally, // so we iterate over a snapshot to avoid index shifting during the loop. - const stripped = stripAnsi(output) + const raw = recentRawOutput() for (const waiter of [...outputWaiters]) { - if (stripped.includes(waiter.text) || output.includes(waiter.text)) { + if (normalizedChunk.includes(waiter.text) || raw.includes(waiter.text)) { waiter.resolve() } } @@ -164,7 +186,11 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({ // Reject any remaining output waiters. reject() removes each waiter // from outputWaiters, so iterate over a snapshot to avoid skipping. for (const waiter of [...outputWaiters]) { - waiter.reject(new Error(`Process exited (code ${code}) while waiting for output: "${waiter.text}"`)) + waiter.reject( + new Error( + `Process exited (code ${code}) while waiting for output: "${waiter.text}"\n\nCaptured output:\n${stripAnsi(output)}`, + ), + ) } }) @@ -176,7 +202,7 @@ export const cliFixture = envFixture.extend<{cli: CLIProcess}>({ typeof opts === 'number' ? {timeoutMs: opts, signal: undefined} : opts // Check if already in output (raw or stripped) - if (stripAnsi(output).includes(text) || output.includes(text)) { + if (searchableOutput().includes(text) || recentRawOutput().includes(text)) { return Promise.resolve() } if (signal?.aborted) { diff --git a/packages/e2e/tests/app-dev-server.spec.ts b/packages/e2e/tests/app-dev-server.spec.ts index 19bbf43ec57..197bfe73188 100644 --- a/packages/e2e/tests/app-dev-server.spec.ts +++ b/packages/e2e/tests/app-dev-server.spec.ts @@ -35,7 +35,7 @@ test.describe('App dev server', () => { // Step 2: Start dev server via PTY, targeting the worker's store const dev = await cli.spawn(['app', 'dev', '--path', appDir], { - env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}, + env: {CI: undefined, SHOPIFY_FLAG_STORE: storeFqdn}, }) // Step 3: Wait for the ready message diff --git a/packages/e2e/tests/dev-hot-reload.spec.ts b/packages/e2e/tests/dev-hot-reload.spec.ts index f976451aaf5..3bfa27555f4 100644 --- a/packages/e2e/tests/dev-hot-reload.spec.ts +++ b/packages/e2e/tests/dev-hot-reload.spec.ts @@ -57,7 +57,7 @@ test.describe('Dev hot reload', () => { injectFixtureToml(appDir, FIXTURE_TOML, appName) const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { - env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}, + env: {CI: undefined, SHOPIFY_FLAG_STORE: storeFqdn}, }) try { @@ -122,7 +122,7 @@ test.describe('Dev hot reload', () => { injectFixtureToml(appDir, FIXTURE_TOML, appName) const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { - env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}, + env: {CI: undefined, SHOPIFY_FLAG_STORE: storeFqdn}, }) try { @@ -180,7 +180,7 @@ test.describe('Dev hot reload', () => { injectFixtureToml(appDir, FIXTURE_TOML, appName) const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { - env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}, + env: {CI: undefined, SHOPIFY_FLAG_STORE: storeFqdn}, }) try { diff --git a/packages/e2e/tests/multi-config-dev.spec.ts b/packages/e2e/tests/multi-config-dev.spec.ts index d2559fc783e..2e2059c6767 100644 --- a/packages/e2e/tests/multi-config-dev.spec.ts +++ b/packages/e2e/tests/multi-config-dev.spec.ts @@ -67,7 +67,7 @@ extensions_summary = "E2E staging app extensions" // --config and --client-id are mutually exclusive. CLIENT_ID is stripped globally in env.ts. const proc = await cli.spawn( ['app', 'dev', '--path', appDir, '-c', 'staging', '--skip-dependencies-installation'], - {env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}}, + {env: {CI: undefined, SHOPIFY_FLAG_STORE: storeFqdn}}, ) try { @@ -149,7 +149,7 @@ extensions_summary = "E2E staging app extensions" // Start dev without -c flag — should use shopify.app.toml const proc = await cli.spawn(['app', 'dev', '--path', appDir, '--skip-dependencies-installation'], { - env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}, + env: {CI: undefined, SHOPIFY_FLAG_STORE: storeFqdn}, }) try { diff --git a/packages/e2e/tests/toml-config.spec.ts b/packages/e2e/tests/toml-config.spec.ts index 3f93816cb95..42e167c7bbe 100644 --- a/packages/e2e/tests/toml-config.spec.ts +++ b/packages/e2e/tests/toml-config.spec.ts @@ -68,7 +68,9 @@ test.describe('TOML config regression', () => { injectFixtureToml(appDir, FIXTURE_TOML, appName) appUrl = devDashboardAppUrl(appDir, env.orgId) - const proc = await cli.spawn(['app', 'dev', '--path', appDir], {env: {CI: '', SHOPIFY_FLAG_STORE: storeFqdn}}) + const proc = await cli.spawn(['app', 'dev', '--path', appDir], { + env: {CI: undefined, SHOPIFY_FLAG_STORE: storeFqdn}, + }) try { await proc.waitForOutput('Ready, watching for changes in your app', CLI_TIMEOUT.medium)