diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index d0e9dee537d..28d36689bcf 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -824,26 +824,6 @@ function TerminalPane( : quickCommandRepoId ? 'This Repo' : null - const { - hosts: quickCommandHosts, - refreshRemoteHost: refreshQuickCommandRemoteHost, - remoteHostLoadFailed: quickCommandHostLoadFailed, - remoteHostPending: quickCommandHostOwnershipPending - } = useTerminalQuickCommandHosts(worktreeId) - const visibleQuickCommandHosts = quickCommandHosts.map((host) => { - const commands = host.commands.filter(isTerminalQuickCommandComplete) - return { - globalCommands: commands.filter( - (command) => getTerminalQuickCommandScope(command).type === 'global' - ), - hostId: host.hostId, - label: host.label, - repoCommands: commands.filter((command) => { - const scope = getTerminalQuickCommandScope(command) - return scope.type === 'repo' && terminalQuickCommandMatchesRepo(command, quickCommandRepoId) - }) - } - }) const quickCommandGroupId = useAppStore( (s) => @@ -2569,6 +2549,32 @@ function TerminalPane( forceBracketedMultilineTextPaste, rightClickToPaste }) + const { + hosts: quickCommandHosts, + refreshRemoteHost: refreshQuickCommandRemoteHost, + remoteHostLoadFailed: quickCommandHostLoadFailed, + remoteHostPending: quickCommandHostOwnershipPending + } = useTerminalQuickCommandHosts(worktreeId, contextMenu.open) + const visibleQuickCommandHosts = useMemo( + () => + quickCommandHosts.map((host) => { + const commands = host.commands.filter(isTerminalQuickCommandComplete) + return { + globalCommands: commands.filter( + (command) => getTerminalQuickCommandScope(command).type === 'global' + ), + hostId: host.hostId, + label: host.label, + repoCommands: commands.filter((command) => { + const scope = getTerminalQuickCommandScope(command) + return ( + scope.type === 'repo' && terminalQuickCommandMatchesRepo(command, quickCommandRepoId) + ) + }) + } + }), + [quickCommandHosts, quickCommandRepoId] + ) useEffect(() => { if (contextMenu.open) { refreshQuickCommandRemoteHost() diff --git a/src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts b/src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts index 082683dde96..c3014b35d8c 100644 --- a/src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts +++ b/src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts @@ -11,18 +11,23 @@ import type { RuntimeTerminalQuickCommands } from '@/store/slices/terminal-quick const testState = vi.hoisted(() => ({ executionHostId: 'runtime:build' as ExecutionHostId, loadRuntimeTerminalQuickCommands: vi.fn(async () => {}), + resolveExecutionHostId: vi.fn(() => 'runtime:build' as ExecutionHostId), runtimeEnvironments: [] as { id: string; name: string }[], runtimeStatusByEnvironmentId: new Map(), runtimeTerminalQuickCommands: new Map(), - settings: null as GlobalSettings | null + settings: null as GlobalSettings | null, + subscribedSelectors: [] as ((state: unknown) => unknown)[] })) vi.mock('@/store', () => ({ - useAppStore: (selector: (state: typeof testState) => unknown) => selector(testState) + useAppStore: (selector: (state: typeof testState) => unknown) => { + testState.subscribedSelectors.push(selector as (state: unknown) => unknown) + return selector(testState) + } })) vi.mock('@/lib/worktree-runtime-owner', () => ({ - getExecutionHostIdForWorktree: () => testState.executionHostId + getExecutionHostIdForWorktree: () => testState.resolveExecutionHostId() })) import { @@ -34,12 +39,16 @@ import { } from './use-terminal-quick-command-hosts' let renderedHosts: TerminalQuickCommandHost[] = [] +let renderedExecutionHostId: ExecutionHostId = 'local' +let refreshRemoteHost = (): void => {} let remoteHostLoadFailed = false let remoteHostPending = false -function Probe(): null { - const result = useTerminalQuickCommandHosts('worktree-1') +function Probe({ enabled = true }: { enabled?: boolean }): null { + const result = useTerminalQuickCommandHosts('worktree-1', enabled) + renderedExecutionHostId = result.executionHostId renderedHosts = result.hosts + refreshRemoteHost = result.refreshRemoteHost remoteHostLoadFailed = result.remoteHostLoadFailed remoteHostPending = result.remoteHostPending return null @@ -51,10 +60,13 @@ describe('useTerminalQuickCommandHosts', () => { beforeEach(() => { testState.executionHostId = 'runtime:build' testState.loadRuntimeTerminalQuickCommands.mockClear() + testState.resolveExecutionHostId.mockClear() + testState.resolveExecutionHostId.mockImplementation(() => testState.executionHostId) testState.runtimeEnvironments = [{ id: 'build', name: 'Build Server' }] testState.runtimeStatusByEnvironmentId = new Map([['build', { connectionGeneration: 4 }]]) testState.runtimeTerminalQuickCommands = new Map() testState.settings = getDefaultSettings('/tmp') + testState.subscribedSelectors = [] renderedHosts = [] remoteHostLoadFailed = false remoteHostPending = false @@ -75,6 +87,37 @@ describe('useTerminalQuickCommandHosts', () => { ).toBe(true) }) + it('skips host resolution and remote loading while disabled', async () => { + await act(async () => root.render(createElement(Probe, { enabled: false }))) + + const disabledHosts = renderedHosts + expect(testState.subscribedSelectors).toHaveLength(1) + for (let write = 0; write < 1_000; write += 1) { + testState.subscribedSelectors[0](testState) + } + refreshRemoteHost() + + expect(renderedExecutionHostId).toBe('local') + expect(renderedHosts).toEqual([]) + expect(remoteHostLoadFailed).toBe(false) + expect(remoteHostPending).toBe(false) + expect(testState.resolveExecutionHostId).not.toHaveBeenCalled() + expect(testState.loadRuntimeTerminalQuickCommands).not.toHaveBeenCalled() + + testState.executionHostId = 'runtime:other' + await act(async () => root.render(createElement(Probe, { enabled: false }))) + + expect(renderedHosts).toBe(disabledHosts) + expect(testState.resolveExecutionHostId).not.toHaveBeenCalled() + expect(testState.loadRuntimeTerminalQuickCommands).not.toHaveBeenCalled() + + testState.executionHostId = 'runtime:build' + await act(async () => root.render(createElement(Probe))) + + expect(testState.resolveExecutionHostId).toHaveBeenCalledOnce() + expect(testState.loadRuntimeTerminalQuickCommands).toHaveBeenCalledWith('build') + }) + it.each([ { name: 'unsupported', diff --git a/src/renderer/src/hooks/use-terminal-quick-command-hosts.ts b/src/renderer/src/hooks/use-terminal-quick-command-hosts.ts index a70e0e750ba..f8239f1dcb4 100644 --- a/src/renderer/src/hooks/use-terminal-quick-command-hosts.ts +++ b/src/renderer/src/hooks/use-terminal-quick-command-hosts.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' import type { GlobalSettings, TerminalQuickCommand } from '../../../shared/types' import { LOCAL_EXECUTION_HOST_ID, @@ -9,6 +10,10 @@ import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overr import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry' import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' import { useAppStore } from '@/store' +import type { + RuntimeTerminalQuickCommands, + TerminalQuickCommandHostsSlice +} from '@/store/slices/terminal-quick-command-hosts' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' export type TerminalQuickCommandHost = { @@ -31,6 +36,30 @@ export type HostedTerminalQuickCommand = { key: string } +type TerminalQuickCommandHostState = { + executionHostId: ExecutionHostId + loadRemote: TerminalQuickCommandHostsSlice['loadRuntimeTerminalQuickCommands'] | null + remoteConnectionGeneration: number + remoteEnvironmentId: string | null + remoteHostId: ExecutionHostId | null + remoteState: RuntimeTerminalQuickCommands | undefined + runtimeEnvironments: PublicKnownRuntimeEnvironment[] + settings: GlobalSettings | null +} + +const EMPTY_RUNTIME_ENVIRONMENTS: PublicKnownRuntimeEnvironment[] = [] +const DISABLED_TERMINAL_QUICK_COMMAND_HOSTS: TerminalQuickCommandHost[] = [] +const DISABLED_TERMINAL_QUICK_COMMAND_HOST_STATE: TerminalQuickCommandHostState = { + executionHostId: LOCAL_EXECUTION_HOST_ID, + loadRemote: null, + remoteConnectionGeneration: 0, + remoteEnvironmentId: null, + remoteHostId: null, + remoteState: undefined, + runtimeEnvironments: EMPTY_RUNTIME_ENVIRONMENTS, + settings: null +} + export function getHostedTerminalQuickCommandKey( hostId: ExecutionHostId, commandId: string @@ -68,41 +97,59 @@ export function getTerminalQuickCommandHostOptions( }).map((host) => ({ id: host.id, label: host.label })) } -export function useTerminalQuickCommandHosts(worktreeId: string): { +export function useTerminalQuickCommandHosts( + worktreeId: string, + enabled = true +): { executionHostId: ExecutionHostId hosts: TerminalQuickCommandHost[] refreshRemoteHost: () => void remoteHostLoadFailed: boolean remoteHostPending: boolean } { - const executionHostId = useAppStore((state) => getExecutionHostIdForWorktree(state, worktreeId)) - const settings = useAppStore((state) => state.settings) - const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments) - const remoteState = useAppStore((state) => { - const parsed = parseExecutionHostId(executionHostId) - return parsed?.kind === 'runtime' - ? state.runtimeTerminalQuickCommands.get(parsed.environmentId) - : undefined - }) - const loadRemote = useAppStore((state) => state.loadRuntimeTerminalQuickCommands) - const parsedExecutionHost = parseExecutionHostId(executionHostId) - const remoteHostId = parsedExecutionHost?.kind === 'runtime' ? parsedExecutionHost.id : null - const remoteEnvironmentId = - parsedExecutionHost?.kind === 'runtime' ? parsedExecutionHost.environmentId : null - const remoteConnectionGeneration = useAppStore((state) => - remoteEnvironmentId - ? (state.runtimeStatusByEnvironmentId.get(remoteEnvironmentId)?.connectionGeneration ?? 0) - : 0 + const { + executionHostId, + loadRemote, + remoteConnectionGeneration, + remoteEnvironmentId, + remoteHostId, + remoteState, + runtimeEnvironments, + settings + } = useAppStore( + useShallow((state): TerminalQuickCommandHostState => { + if (!enabled) { + return DISABLED_TERMINAL_QUICK_COMMAND_HOST_STATE + } + const executionHostId = getExecutionHostIdForWorktree(state, worktreeId) + const parsedExecutionHost = parseExecutionHostId(executionHostId) + const remoteEnvironmentId = + parsedExecutionHost?.kind === 'runtime' ? parsedExecutionHost.environmentId : null + return { + executionHostId, + loadRemote: state.loadRuntimeTerminalQuickCommands, + remoteConnectionGeneration: remoteEnvironmentId + ? (state.runtimeStatusByEnvironmentId.get(remoteEnvironmentId)?.connectionGeneration ?? 0) + : 0, + remoteEnvironmentId, + remoteHostId: parsedExecutionHost?.kind === 'runtime' ? parsedExecutionHost.id : null, + remoteState: remoteEnvironmentId + ? state.runtimeTerminalQuickCommands.get(remoteEnvironmentId) + : undefined, + runtimeEnvironments: state.runtimeEnvironments, + settings: state.settings + } + }) ) useEffect(() => { - if (remoteEnvironmentId) { + if (loadRemote && remoteEnvironmentId) { void loadRemote(remoteEnvironmentId) } }, [loadRemote, remoteConnectionGeneration, remoteEnvironmentId]) const refreshRemoteHost = useCallback((): void => { - if (remoteEnvironmentId) { + if (loadRemote && remoteEnvironmentId) { void loadRemote(remoteEnvironmentId, { force: true }) } }, [loadRemote, remoteEnvironmentId]) @@ -122,6 +169,9 @@ export function useTerminalQuickCommandHosts(worktreeId: string): { ) const hosts = useMemo(() => { + if (!enabled) { + return DISABLED_TERMINAL_QUICK_COMMAND_HOSTS + } const hostOptions = getTerminalQuickCommandHostOptions(settings, runtimeEnvironments) const result: TerminalQuickCommandHost[] = [ { @@ -146,6 +196,7 @@ export function useTerminalQuickCommandHosts(worktreeId: string): { }) return result }, [ + enabled, remoteConnectionGeneration, remoteEnvironmentId, remoteHostId,