diff --git a/packages/devtools-kit/package.json b/packages/devtools-kit/package.json index f94baa6207..3d03745a0e 100644 --- a/packages/devtools-kit/package.json +++ b/packages/devtools-kit/package.json @@ -32,6 +32,7 @@ "scripts": { "build": "unbuild", "stub": "unbuild --stub", + "dev:prepare": "nr stub", "prepublishOnly": "pnpm build" }, "peerDependencies": { diff --git a/packages/devtools-kit/src/_types/custom-tabs.ts b/packages/devtools-kit/src/_types/custom-tabs.ts index 61185cc7f8..d7e8577af6 100644 --- a/packages/devtools-kit/src/_types/custom-tabs.ts +++ b/packages/devtools-kit/src/_types/custom-tabs.ts @@ -106,6 +106,7 @@ export interface ModuleBuiltinTab { title?: string path?: string requireClient?: boolean + shouldShow?: () => boolean } export type ModuleTabInfo = ModuleCustomTab | ModuleBuiltinTab diff --git a/packages/devtools-kit/src/_types/hooks.ts b/packages/devtools-kit/src/_types/hooks.ts index 09b5e5e385..4b7fba340d 100644 --- a/packages/devtools-kit/src/_types/hooks.ts +++ b/packages/devtools-kit/src/_types/hooks.ts @@ -41,6 +41,11 @@ declare module '@nuxt/schema' { * Returns true if terminal is found and deleted. */ 'devtools:terminal:remove': (id: string) => void + + /** + * Mark a terminal as terminated. + */ + 'devtools:terminal:exit': (id: string, code?: number) => void } } @@ -52,7 +57,7 @@ declare module '@nuxt/schema' { /** * On terminal data. */ - 'devtools:terminal:data': (id: string, data: string) => void + 'devtools:terminal:data': (payload: { id: string; data: string }) => void } } diff --git a/packages/devtools-kit/src/_types/integrations.ts b/packages/devtools-kit/src/_types/integrations.ts index b7355e4f3a..a1f81699a0 100644 --- a/packages/devtools-kit/src/_types/integrations.ts +++ b/packages/devtools-kit/src/_types/integrations.ts @@ -18,11 +18,20 @@ export interface ImageMeta { mimeType?: string } -export interface UpdateInfo { +export interface PackageUpdateInfo { name: string current: string - latest?: string - needsUpdate?: boolean + latest: string + needsUpdate: boolean +} + +export type PackageManagerName = 'npm' | 'yarn' | 'pnpm' + +export type NpmCommandType = 'install' | 'uninstall' | 'update' + +export interface NpmCommandOptions { + dev?: boolean + global?: boolean } export interface AutoImportsWithMetadata { diff --git a/packages/devtools-kit/src/_types/rpc.ts b/packages/devtools-kit/src/_types/rpc.ts index 2eb5952975..ee713be5f3 100644 --- a/packages/devtools-kit/src/_types/rpc.ts +++ b/packages/devtools-kit/src/_types/rpc.ts @@ -3,7 +3,7 @@ import type { StorageMounts } from 'nitropack' import type { StorageValue } from 'unstorage' import type { Component } from 'vue' import type { ModuleCustomTab } from './custom-tabs' -import type { AssetInfo, AutoImportsWithMetadata, ComponentRelationship, HookInfo, ImageMeta, UpdateInfo } from './integrations' +import type { AssetInfo, AutoImportsWithMetadata, ComponentRelationship, HookInfo, ImageMeta, NpmCommandOptions, NpmCommandType, PackageManagerName, PackageUpdateInfo } from './integrations' import type { TerminalAction, TerminalInfo } from './terminals' import type { GetWizardArgs, WizardActions } from './wizard' @@ -18,7 +18,12 @@ export interface ServerFunctions { getServerHooks(): HookInfo[] getServerLayouts(): NuxtLayout[] getStaticAssets(): Promise - getPackageVersions(): UpdateInfo[] + + // Updates + checkForUpdateFor(name: string): Promise + getPackageManager(): Promise + getNpmCommand(command: NpmCommandType, packageName: string, options?: NpmCommandOptions): Promise + runNpmCommand(command: NpmCommandType, packageName: string, options?: NpmCommandOptions): Promise<{ processId: string } | undefined> // Terminal getTerminals(): TerminalInfo[] @@ -40,6 +45,7 @@ export interface ServerFunctions { customTabAction(name: string, action: number): Promise runWizard(name: T, ...args: GetWizardArgs): Promise openInEditor(filepath: string): void + restartNuxt(hard?: boolean): Promise } export interface ClientFunctions { @@ -48,6 +54,7 @@ export interface ClientFunctions { navigateTo(path: string): void onTerminalData(id: string, data: string): void + onTerminalExit(id: string, code: number): void } export type ClientUpdateEvent = keyof ServerFunctions diff --git a/packages/devtools-kit/src/_types/terminals.ts b/packages/devtools-kit/src/_types/terminals.ts index 0318c434d3..16f36d4565 100644 --- a/packages/devtools-kit/src/_types/terminals.ts +++ b/packages/devtools-kit/src/_types/terminals.ts @@ -1,3 +1,5 @@ +import type { Options as ExecaOptions } from 'execa' + export interface TerminalBase { id: string name: string @@ -5,15 +7,35 @@ export interface TerminalBase { icon?: string } -export type TerminalAction = 'restart' | 'terminate' | 'clear' +export type TerminalAction = 'restart' | 'terminate' | 'clear' | 'remove' + +export interface SubprocessOptions extends ExecaOptions { + command: string + args?: string[] +} export interface TerminalInfo extends TerminalBase { + /** + * Whether the terminal can be restarted + */ restartable?: boolean + /** + * Whether the terminal can be terminated + */ terminatable?: boolean + + /** + * Whether the terminal is terminated + */ + isTerminated?: boolean + + /** + * Content buffer + */ buffer?: string } -export interface TerminalState extends TerminalBase { +export interface TerminalState extends TerminalInfo { /** * User action to restart the terminal, when not provided, this action will be disabled */ @@ -23,9 +45,4 @@ export interface TerminalState extends TerminalBase { * User action to terminate the terminal, when not provided, this action will be disabled */ onActionTerminate?: () => Promise | void - - /** - * Content buffer - */ - buffer?: string } diff --git a/packages/devtools-kit/src/index.ts b/packages/devtools-kit/src/index.ts index b26c50c232..e616607c39 100644 --- a/packages/devtools-kit/src/index.ts +++ b/packages/devtools-kit/src/index.ts @@ -1,8 +1,7 @@ import { useNuxt } from '@nuxt/kit' import type { BirpcGroup } from 'birpc' -import type { Options as ExecaOptions } from 'execa' import { execa } from 'execa' -import type { ModuleCustomTab, NuxtDevtoolsServerContext, TerminalState } from './types' +import type { ModuleCustomTab, NuxtDevtoolsServerContext, SubprocessOptions, TerminalState } from './types' /** * Hooks to extend a custom tab in devtools. @@ -24,11 +23,6 @@ export function refreshCustomTabs(nuxt = useNuxt()) { return nuxt.callHook('devtools:customTabs:refresh') } -export interface SubprocessOptions extends ExecaOptions { - command: string - args?: string[] -} - /** * Create a subprocess that handled by the DevTools. */ @@ -38,6 +32,7 @@ export function startSubprocess( nuxt = useNuxt(), ) { const id = tabOptions.id + let restarting = false function start() { const process = execa( @@ -53,14 +48,19 @@ export function startSubprocess( }, ) + nuxt.callHook('devtools:terminal:write', id, `> ${[execaOptions.command, ...execaOptions.args || []].join(' ')}\n\n`) + process.stdout!.on('data', (data) => { - nuxt.callHook('devtools:terminal:write', id, data) + nuxt.callHook('devtools:terminal:write', id, data.toString()) }) process.stderr!.on('data', (data) => { - nuxt.callHook('devtools:terminal:write', id, data) + nuxt.callHook('devtools:terminal:write', id, data.toString()) }) process.on('exit', (code) => { - nuxt.callHook('devtools:terminal:write', id, `\nprocess terminalated with ${code}\n`) + if (!restarting) { + nuxt.callHook('devtools:terminal:write', id, `\n> process terminalated with ${code}\n`) + nuxt.callHook('devtools:terminal:exit', id, code || 0) + } }) return process @@ -74,10 +74,12 @@ export function startSubprocess( let process = start() function restart() { + restarting = true process?.kill() clear() process = start() + restarting = false } function clear() { @@ -86,6 +88,7 @@ export function startSubprocess( } function terminate() { + restarting = false try { process?.kill() } @@ -96,8 +99,9 @@ export function startSubprocess( function register() { nuxt.callHook('devtools:terminal:register', { - onActionRestart: restart, - onActionTerminate: terminate, + onActionRestart: tabOptions.restartable === false ? undefined : restart, + onActionTerminate: tabOptions.terminatable === false ? undefined : terminate, + isTerminated: false, ...tabOptions, }) } diff --git a/packages/devtools/client/components/Badge.vue b/packages/devtools/client/components/Badge.vue index 1a71a0cacc..dbdc1b0ebb 100644 --- a/packages/devtools/client/components/Badge.vue +++ b/packages/devtools/client/components/Badge.vue @@ -1,5 +1,5 @@ diff --git a/packages/devtools/client/components/ModuleItem.vue b/packages/devtools/client/components/ModuleItem.vue index 92986d420f..fc9076578a 100644 --- a/packages/devtools/client/components/ModuleItem.vue +++ b/packages/devtools/client/components/ModuleItem.vue @@ -36,7 +36,7 @@ const npmBase = 'https://www.npmjs.com/package/'
+ + + + +
+import type { NpmCommandOptions } from '../../src/types' + +const props = defineProps<{ + packageName: string + options?: NpmCommandOptions +}>() + +const { + info, + update, + state, + processId, + restart, +} = usePackageUpdate(props.packageName, props.options) + + + diff --git a/packages/devtools/client/components/RoutesTable.vue b/packages/devtools/client/components/RoutesTable.vue index aa052813de..997e67c23e 100644 --- a/packages/devtools/client/components/RoutesTable.vue +++ b/packages/devtools/client/components/RoutesTable.vue @@ -44,7 +44,7 @@ function openLayout(name: string) { -
+
- + {{ item.name }} diff --git a/packages/devtools/client/components/SideNavItem.vue b/packages/devtools/client/components/SideNavItem.vue index c1641d4366..3613fef815 100644 --- a/packages/devtools/client/components/SideNavItem.vue +++ b/packages/devtools/client/components/SideNavItem.vue @@ -9,6 +9,8 @@ const settings = useDevToolsSettings() const client = useClient() const isEnabled = computed(() => { const _tab = props.tab as ModuleBuiltinTab + if (_tab.shouldShow && !_tab.shouldShow()) + return false if (_tab.requireClient && !client.value) return false if (settings.hiddenTabs.value.includes(_tab.name)) @@ -23,7 +25,7 @@ const isEnabled = computed(() => { :to="'path' in tab ? tab.path : `/modules/custom-${tab.name}`" flex="~" hover="bg-active" - items-center justify-center p1 select-none w-10 text-secondary rounded-xl h-10 + select-none items-center justify-center p1 w-10 text-secondary rounded-xl h-10 exact-active-class="!text-primary bg-active" > () const nuxt = useNuxtApp() const info = ref() +const router = useRouter() let term: Terminal onMounted(async () => { @@ -33,7 +34,7 @@ onMounted(async () => { term.write(info.value.buffer) // @ts-expect-error missing hooks type - nuxt.hook('devtools:terminal:data', (id: string, data: string) => { + nuxt.hook('devtools:terminal:data', ({ id, data }) => { if (id === props.id) term.write(data) }) @@ -46,12 +47,11 @@ function clear() { diff --git a/packages/devtools/client/composables/npm.ts b/packages/devtools/client/composables/npm.ts new file mode 100644 index 0000000000..a95f2654a3 --- /dev/null +++ b/packages/devtools/client/composables/npm.ts @@ -0,0 +1,62 @@ +import type { NpmCommandOptions } from '../../src/types' + +export type PackageUpdateState = 'idle' | 'running' | 'updated' + +const map = new Map() + +export function usePackageUpdate(name: string, options?: NpmCommandOptions): ReturnType { + const key = name + if (!map.has(key)) + map.set(key, getPackageUpdate(name, options)) + return map.get(key) +} + +function getPackageUpdate(name: string, options?: NpmCommandOptions) { + const nuxt = useNuxtApp() + const info = useAsyncData(`npm:check:${name}`, () => rpc.checkForUpdateFor(name)).data + const router = useRouter() + + const state = ref('idle') + + const processId = ref() + + // @ts-expect-error missing hooks type + nuxt.hook('devtools:terminal:exit', ({ id, code }) => { + if (id !== processId || !processId) + return + state.value = code === 0 ? 'updated' : 'idle' + }) + + async function update() { + if (state.value !== 'idle') + return + + const command = await rpc.getNpmCommand('update', name, options) + if (!command) + return + + if (!confirm(`Going to run "${command.join(' ')}", are you sure?`)) + return + + state.value = 'running' + + processId.value = (await rpc.runNpmCommand('update', name, options))?.processId + + if (processId.value) + router.push(`/modules/terminals?id=${encodeURIComponent(processId.value)}`) + } + + async function restart() { + if (state.value !== 'updated') + return + await rpc.restartNuxt() + } + + return { + info, + state, + update, + restart, + processId, + } +} diff --git a/packages/devtools/client/composables/state.ts b/packages/devtools/client/composables/state.ts index bf867c79d5..e07fdec07b 100644 --- a/packages/devtools/client/composables/state.ts +++ b/packages/devtools/client/composables/state.ts @@ -37,10 +37,6 @@ export function useComponents() { ].sort((a: any, b: any) => a.pascalName.localeCompare(b.pascalName))) } -export function getPackageVersions() { - return useAsyncState('getPackageVersions', () => rpc.getPackageVersions()) -} - export function useServerPages() { return useAsyncState('getServerPages', () => rpc.getServerPages()) } diff --git a/packages/devtools/client/pages/modules/modules.vue b/packages/devtools/client/pages/modules/modules.vue index a16b326d62..49af8bee86 100644 --- a/packages/devtools/client/pages/modules/modules.vue +++ b/packages/devtools/client/pages/modules/modules.vue @@ -57,14 +57,15 @@ watchEffect(() => { v-if="userModules.length" icon="carbon-3d-mpr-toggle" text="User Modules" - container-class="grid grid-cols-minmax-400px gap2" + container-class="grid grid-cols-minmax-450px gap2" :description="`Total modules: ${userModules.length}`" > - +
diff --git a/packages/devtools/client/pages/modules/overview.vue b/packages/devtools/client/pages/modules/overview.vue index ad13421609..2e1c25120d 100644 --- a/packages/devtools/client/pages/modules/overview.vue +++ b/packages/devtools/client/pages/modules/overview.vue @@ -9,14 +9,11 @@ definePageMeta({ const client = useClient() const config = useServerConfig() -const versions = getPackageVersions() const components = useComponents() const autoImports = useAutoImports() const routes = useAllRoutes() const isMacOS = getIsMacOS() -const nuxtVersion = computed(() => versions.value?.find(v => v.name === 'nuxt')) - const router = useRouter() function goIntro() { isFirstVisit.value = true @@ -25,7 +22,7 @@ function goIntro() {