Add scoped terminal quick commands (#2277)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
9c67557a0c
commit
87a83ae4f2
|
|
@ -63,6 +63,7 @@ import { GhosttyImportModal } from './GhosttyImportModal'
|
|||
import type { UseGhosttyImportReturn } from './useGhosttyImport'
|
||||
import { ManageSessionsSection } from './ManageSessionsSection'
|
||||
import { TerminalQuickCommandsSection } from './TerminalQuickCommandsSection'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
|
||||
|
||||
type TerminalPaneProps = {
|
||||
settings: GlobalSettings
|
||||
|
|
@ -93,6 +94,9 @@ export function TerminalPane({
|
|||
pwshAvailable
|
||||
}: TerminalPaneProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const repos = useAppStore((state) => state.repos)
|
||||
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
|
||||
const activeRepoId = activeWorktreeId ? getRepoIdFromWorktreeId(activeWorktreeId) : null
|
||||
const isWindows = isWindowsUserAgent()
|
||||
const isMac = isMacUserAgent()
|
||||
const [themeSearchDark, setThemeSearchDark] = useState('')
|
||||
|
|
@ -276,18 +280,29 @@ export function TerminalPane({
|
|||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Quick Commands</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Save terminal input snippets for the terminal right-click menu.
|
||||
Save global and repository-specific terminal snippets for the right-click menu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title="Quick Commands"
|
||||
description="Create, edit, and remove terminal command snippets for the right-click menu."
|
||||
keywords={['terminal', 'command', 'snippet', 'quick command', 'send', 'context menu']}
|
||||
description="Create, edit, and remove scoped terminal command snippets for the right-click menu."
|
||||
keywords={[
|
||||
'terminal',
|
||||
'command',
|
||||
'snippet',
|
||||
'quick command',
|
||||
'send',
|
||||
'context menu',
|
||||
'repo',
|
||||
'repository'
|
||||
]}
|
||||
className="space-y-3"
|
||||
>
|
||||
<TerminalQuickCommandsSection
|
||||
commands={settings.terminalQuickCommands ?? []}
|
||||
repos={repos}
|
||||
activeRepoId={activeRepoId}
|
||||
onChange={(terminalQuickCommands) => updateSettings({ terminalQuickCommands })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,31 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import type { TerminalQuickCommand } from '../../../../shared/types'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { Button } from '../ui/button'
|
||||
import type {
|
||||
Repo,
|
||||
TerminalQuickCommand,
|
||||
TerminalQuickCommandScope
|
||||
} from '../../../../shared/types'
|
||||
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import { Input } from '../ui/input'
|
||||
createTerminalQuickCommandDraft,
|
||||
TerminalQuickCommandDialog
|
||||
} from '@/components/terminal-quick-commands/TerminalQuickCommandDialog'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
|
||||
import RepoDotLabel from '../repo/RepoDotLabel'
|
||||
|
||||
type TerminalQuickCommandsSectionProps = {
|
||||
commands: TerminalQuickCommand[]
|
||||
repos: Pick<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
|
||||
activeRepoId: string | null
|
||||
onChange: (commands: TerminalQuickCommand[]) => void
|
||||
}
|
||||
|
||||
type ScopeFilter = 'all' | 'global' | 'repo'
|
||||
|
||||
type EditorState =
|
||||
| {
|
||||
mode: 'add'
|
||||
|
|
@ -30,51 +37,94 @@ type EditorState =
|
|||
}
|
||||
| null
|
||||
|
||||
function createQuickCommand(): TerminalQuickCommand {
|
||||
return {
|
||||
id: `quick-command-${createBrowserUuid()}`,
|
||||
label: '',
|
||||
command: '',
|
||||
appendEnter: true
|
||||
function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string {
|
||||
return repo.displayName || repo.path
|
||||
}
|
||||
|
||||
function getScopeLabel(
|
||||
scope: TerminalQuickCommandScope,
|
||||
repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>>
|
||||
): string {
|
||||
if (scope.type === 'global') {
|
||||
return 'Global'
|
||||
}
|
||||
const repo = repoById.get(scope.repoId)
|
||||
return repo ? getRepoLabel(repo) : 'Missing repo'
|
||||
}
|
||||
|
||||
export function TerminalQuickCommandsSection({
|
||||
commands,
|
||||
repos,
|
||||
activeRepoId,
|
||||
onChange
|
||||
}: TerminalQuickCommandsSectionProps): React.JSX.Element {
|
||||
const [editor, setEditor] = useState<EditorState>(null)
|
||||
const [draft, setDraft] = useState<TerminalQuickCommand>(createQuickCommand)
|
||||
const [scopeFilter, setScopeFilter] = useState<ScopeFilter>('all')
|
||||
const [repoFilterId, setRepoFilterId] = useState(activeRepoId ?? '')
|
||||
const [repoFilterManuallyChanged, setRepoFilterManuallyChanged] = useState(false)
|
||||
const repoById = new Map(repos.map((repo) => [repo.id, repo]))
|
||||
const activeRepoFilterId = activeRepoId && repoById.has(activeRepoId) ? activeRepoId : ''
|
||||
const repoFilterIsValid = repoFilterId !== '' && repoById.has(repoFilterId)
|
||||
const selectedRepoId =
|
||||
activeRepoFilterId && (!repoFilterManuallyChanged || !repoFilterIsValid)
|
||||
? activeRepoFilterId
|
||||
: repoFilterIsValid
|
||||
? repoFilterId
|
||||
: (repos[0]?.id ?? '')
|
||||
const visibleCommands = commands.filter((command) => {
|
||||
const scope = getTerminalQuickCommandScope(command)
|
||||
if (scopeFilter === 'global') {
|
||||
return scope.type === 'global'
|
||||
}
|
||||
if (scopeFilter === 'repo') {
|
||||
return scope.type === 'repo' && (!selectedRepoId || scope.repoId === selectedRepoId)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const createDraftForCurrentFilter = (): TerminalQuickCommand => {
|
||||
if (scopeFilter === 'repo' && selectedRepoId) {
|
||||
return createTerminalQuickCommandDraft({ type: 'repo', repoId: selectedRepoId })
|
||||
}
|
||||
return createTerminalQuickCommandDraft({ type: 'global' })
|
||||
}
|
||||
|
||||
// Follow the active worktree until the user picks a repo; resume if that repo disappears.
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
setDraft({ ...editor.command })
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const saveDraft = (): void => {
|
||||
const next = {
|
||||
...draft,
|
||||
label: draft.label.trim(),
|
||||
command: draft.command.trimEnd()
|
||||
}
|
||||
if (!next.label || !next.command) {
|
||||
if (!activeRepoFilterId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!repoFilterManuallyChanged) {
|
||||
if (repoFilterId !== activeRepoFilterId) {
|
||||
setRepoFilterId(activeRepoFilterId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!repoFilterIsValid) {
|
||||
setRepoFilterId(activeRepoFilterId)
|
||||
setRepoFilterManuallyChanged(false)
|
||||
}
|
||||
}, [activeRepoFilterId, repoFilterId, repoFilterIsValid, repoFilterManuallyChanged])
|
||||
|
||||
const changeRepoFilter = (nextRepoId: string): void => {
|
||||
setRepoFilterManuallyChanged(true)
|
||||
setRepoFilterId(nextRepoId)
|
||||
}
|
||||
|
||||
const saveCommand = (next: TerminalQuickCommand): void => {
|
||||
if (editor?.mode === 'edit') {
|
||||
onChange(commands.map((command) => (command.id === next.id ? next : command)))
|
||||
} else {
|
||||
onChange([...commands, next])
|
||||
}
|
||||
setEditor(null)
|
||||
}
|
||||
|
||||
const removeCommand = (id: string): void => {
|
||||
onChange(commands.filter((command) => command.id !== id))
|
||||
}
|
||||
|
||||
const canSave = draft.label.trim().length > 0 && draft.command.trimEnd().length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
|
@ -88,128 +138,111 @@ export function TerminalQuickCommandsSection({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditor({ mode: 'add', command: createQuickCommand() })}
|
||||
onClick={() => setEditor({ mode: 'add', command: createDraftForCurrentFilter() })}
|
||||
>
|
||||
<Plus />
|
||||
Add Command
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={scopeFilter}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'all' || value === 'global' || value === 'repo') {
|
||||
setScopeFilter(value)
|
||||
}
|
||||
}}
|
||||
className="justify-start"
|
||||
>
|
||||
<ToggleGroupItem value="all">All</ToggleGroupItem>
|
||||
<ToggleGroupItem value="global">Global</ToggleGroupItem>
|
||||
<ToggleGroupItem value="repo" disabled={repos.length === 0}>
|
||||
Repository
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{scopeFilter === 'repo' && repos.length > 0 ? (
|
||||
<Select value={selectedRepoId} onValueChange={changeRepoFilter}>
|
||||
<SelectTrigger size="sm" className="min-w-52">
|
||||
<SelectValue placeholder="Choose repository" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repos.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
<RepoDotLabel
|
||||
name={getRepoLabel(repo)}
|
||||
color={repo.badgeColor}
|
||||
className="max-w-full"
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border/50">
|
||||
{commands.length === 0 ? (
|
||||
<div className="px-3 py-6 text-sm text-muted-foreground">No quick commands saved.</div>
|
||||
{visibleCommands.length === 0 ? (
|
||||
<div className="px-3 py-6 text-sm text-muted-foreground">
|
||||
{commands.length === 0 ? 'No quick commands saved.' : 'No commands match this scope.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{commands.map((command) => (
|
||||
<div key={command.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{command.label || 'Untitled'}</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">
|
||||
{command.command || 'No command text'}
|
||||
{visibleCommands.map((command) => {
|
||||
const scope = getTerminalQuickCommandScope(command)
|
||||
return (
|
||||
<div key={command.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{command.label || 'Untitled'}
|
||||
</div>
|
||||
<Badge variant="outline" className="max-w-44">
|
||||
<span className="truncate">{getScopeLabel(scope, repoById)}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">
|
||||
{command.command || 'No command text'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{command.appendEnter ? 'Enter' : 'Insert'}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${command.label || 'quick command'}`}
|
||||
onClick={() => setEditor({ mode: 'edit', command })}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove ${command.label || 'quick command'}`}
|
||||
onClick={() => removeCommand(command.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{command.appendEnter ? 'Enter' : 'Insert'}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${command.label || 'quick command'}`}
|
||||
onClick={() => setEditor({ mode: 'edit', command })}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove ${command.label || 'quick command'}`}
|
||||
onClick={() => removeCommand(command.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={editor !== null} onOpenChange={(open) => !open && setEditor(null)}>
|
||||
<DialogContent className="max-w-md sm:max-w-md" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{editor?.mode === 'edit' ? 'Edit Quick Command' : 'Add Quick Command'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Save terminal input text for the context menu.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
value={draft.label}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, label: event.target.value }))
|
||||
}
|
||||
placeholder="Restart server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Command Text</Label>
|
||||
<textarea
|
||||
value={draft.command}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, command: event.target.value }))
|
||||
}
|
||||
placeholder="npm run dev"
|
||||
rows={4}
|
||||
className="min-h-24 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border border-border/50 px-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">Append Enter</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Submit immediately instead of only inserting text.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={draft.appendEnter}
|
||||
aria-label="Toggle append Enter"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({ ...current, appendEnter: !current.appendEnter }))
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
draft.appendEnter ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
draft.appendEnter ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditor(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={saveDraft} disabled={!canSave}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TerminalQuickCommandDialog
|
||||
open={editor !== null}
|
||||
mode={editor?.mode ?? 'add'}
|
||||
command={editor?.command ?? createTerminalQuickCommandDraft()}
|
||||
repos={repos}
|
||||
onOpenChange={(open) => !open && setEditor(null)}
|
||||
onSave={saveCommand}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
PanelsTopLeft,
|
||||
PanelRightClose,
|
||||
Pencil,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
|
|
@ -41,8 +43,11 @@ type TerminalContextMenuProps = {
|
|||
onEqualizePaneSizes: () => void
|
||||
onClosePane: () => void
|
||||
onClearScreen: () => void
|
||||
quickCommands: TerminalQuickCommand[]
|
||||
repoQuickCommands: TerminalQuickCommand[]
|
||||
globalQuickCommands: TerminalQuickCommand[]
|
||||
quickCommandRepoLabel: string | null
|
||||
onQuickCommand: (command: TerminalQuickCommand) => void
|
||||
onAddQuickCommand: () => void
|
||||
onToggleExpand: () => void
|
||||
onSetTitle: () => void
|
||||
}
|
||||
|
|
@ -63,14 +68,18 @@ export default function TerminalContextMenu({
|
|||
onEqualizePaneSizes,
|
||||
onClosePane,
|
||||
onClearScreen,
|
||||
quickCommands,
|
||||
repoQuickCommands,
|
||||
globalQuickCommands,
|
||||
quickCommandRepoLabel,
|
||||
onQuickCommand,
|
||||
onAddQuickCommand,
|
||||
onToggleExpand,
|
||||
onSetTitle
|
||||
}: TerminalContextMenuProps): React.JSX.Element {
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const mod = isMac ? '⌘' : 'Ctrl+'
|
||||
const shift = isMac ? '⇧' : 'Shift+'
|
||||
const hasQuickCommands = repoQuickCommands.length > 0 || globalQuickCommands.length > 0
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
|
|
@ -126,24 +135,65 @@ export default function TerminalContextMenu({
|
|||
Paste
|
||||
<DropdownMenuShortcut>{mod}V</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{quickCommands.length > 0 ? (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<SquareTerminal />
|
||||
Quick Commands
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-60">
|
||||
{quickCommands.map((command) => (
|
||||
<DropdownMenuItem key={command.id} onSelect={() => onQuickCommand(command)}>
|
||||
<span className="truncate">{command.label}</span>
|
||||
{!command.appendEnter ? (
|
||||
<DropdownMenuShortcut className="shrink-0">Insert</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : null}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<SquareTerminal />
|
||||
Quick Commands
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-60">
|
||||
{hasQuickCommands ? (
|
||||
<>
|
||||
{quickCommandRepoLabel && repoQuickCommands.length > 0 ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="truncate">
|
||||
{quickCommandRepoLabel}
|
||||
</DropdownMenuLabel>
|
||||
{repoQuickCommands.map((command) => (
|
||||
<DropdownMenuItem key={command.id} onSelect={() => onQuickCommand(command)}>
|
||||
<span className="truncate">{command.label}</span>
|
||||
{!command.appendEnter ? (
|
||||
<DropdownMenuShortcut className="shrink-0">Insert</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{globalQuickCommands.length > 0 ? (
|
||||
<>
|
||||
{repoQuickCommands.length > 0 ? <DropdownMenuSeparator /> : null}
|
||||
{repoQuickCommands.length > 0 ? (
|
||||
<DropdownMenuLabel>Global</DropdownMenuLabel>
|
||||
) : null}
|
||||
{globalQuickCommands.map((command) => (
|
||||
<DropdownMenuItem key={command.id} onSelect={() => onQuickCommand(command)}>
|
||||
<span className="truncate">{command.label}</span>
|
||||
{!command.appendEnter ? (
|
||||
<DropdownMenuShortcut className="shrink-0">Insert</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">
|
||||
No quick commands
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
// Why: the dropdown sits above dialogs; force-close before
|
||||
// opening the add modal even during the open-gesture guard.
|
||||
onOpenChange(false)
|
||||
onAddQuickCommand()
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
Add Quick Command…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onSplitRight}>
|
||||
<PanelRightClose />
|
||||
|
|
|
|||
|
|
@ -60,6 +60,17 @@ import {
|
|||
isSyntheticSinglePaneTitle,
|
||||
sanitizeTerminalLayoutPaneTitles
|
||||
} from '@/lib/terminal-pane-title-sanitization'
|
||||
import type { TerminalQuickCommand, TerminalQuickCommandScope } from '../../../../shared/types'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
|
||||
import {
|
||||
getTerminalQuickCommandScope,
|
||||
terminalQuickCommandMatchesRepo
|
||||
} from '../../../../shared/terminal-quick-commands'
|
||||
import {
|
||||
createTerminalQuickCommandDraft,
|
||||
TerminalQuickCommandDialog
|
||||
} from '@/components/terminal-quick-commands/TerminalQuickCommandDialog'
|
||||
|
||||
// Why: registry lives in a leaf module so the store slice can import it
|
||||
// without re-entering the `slice → TerminalPane → store → slice` cycle
|
||||
|
|
@ -148,6 +159,10 @@ export default function TerminalPane({
|
|||
searchOpenRef.current = searchOpen
|
||||
const searchStateRef = useRef<SearchState>({ query: '', caseSensitive: false, regex: false })
|
||||
const [closeConfirmPaneId, setCloseConfirmPaneId] = useState<number | null>(null)
|
||||
const [quickCommandEditorOpen, setQuickCommandEditorOpen] = useState(false)
|
||||
// Why: the terminal menu can be the first quick-command entry point, so each
|
||||
// Add action starts with a fresh draft instead of reusing cancelled text.
|
||||
const [quickCommandDraft, setQuickCommandDraft] = useState(createTerminalQuickCommandDraft)
|
||||
const [terminalError, setTerminalError] = useState<string | null>(null)
|
||||
const [sessionStateSaveFailureOpen, setSessionStateSaveFailureOpen] = useState(false)
|
||||
// Why: override state lives in a plain Map for perf (safeFit reads it on
|
||||
|
|
@ -291,6 +306,8 @@ export default function TerminalPane({
|
|||
const openSpacePage = useAppStore((store) => store.openSpacePage)
|
||||
const refreshWorkspaceSpace = useAppStore((store) => store.refreshWorkspaceSpace)
|
||||
const settings = useAppStore((store) => store.settings)
|
||||
const repos = useAppStore((store) => store.repos)
|
||||
const updateSettings = useAppStore((store) => store.updateSettings)
|
||||
// Why: Windows is the only platform where bare right-click is repurposed as
|
||||
// a paste gesture; on macOS/Linux the terminal still owns right-click for the
|
||||
// context menu. The settings default keeps the Windows shortcut feeling native
|
||||
|
|
@ -319,6 +336,38 @@ export default function TerminalPane({
|
|||
})
|
||||
}, [openSpacePage, refreshWorkspaceSpace])
|
||||
|
||||
const quickCommandRepoId =
|
||||
worktreeId === FLOATING_TERMINAL_WORKTREE_ID ? null : getRepoIdFromWorktreeId(worktreeId)
|
||||
const quickCommandRepo = repos.find((repo) => repo.id === quickCommandRepoId) ?? null
|
||||
const quickCommandRepoLabel = quickCommandRepo
|
||||
? quickCommandRepo.displayName || quickCommandRepo.path
|
||||
: quickCommandRepoId
|
||||
? 'This Repo'
|
||||
: null
|
||||
const validQuickCommands = (settings?.terminalQuickCommands ?? []).filter(
|
||||
(command) => command.label.trim() && command.command.trimEnd()
|
||||
)
|
||||
const repoQuickCommands = validQuickCommands.filter((command) => {
|
||||
const scope = getTerminalQuickCommandScope(command)
|
||||
return scope.type === 'repo' && terminalQuickCommandMatchesRepo(command, quickCommandRepoId)
|
||||
})
|
||||
const globalQuickCommands = validQuickCommands.filter(
|
||||
(command) => getTerminalQuickCommandScope(command).type === 'global'
|
||||
)
|
||||
|
||||
const openQuickCommandEditor = useCallback((scope: TerminalQuickCommandScope): void => {
|
||||
setQuickCommandDraft(createTerminalQuickCommandDraft(scope))
|
||||
setQuickCommandEditorOpen(true)
|
||||
}, [])
|
||||
|
||||
const saveQuickCommand = useCallback(
|
||||
(command: TerminalQuickCommand): void => {
|
||||
const currentCommands = useAppStore.getState().settings?.terminalQuickCommands ?? []
|
||||
void updateSettings({ terminalQuickCommands: [...currentCommands, command] })
|
||||
},
|
||||
[updateSettings]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (setupSplit) {
|
||||
consumeTabSetupSplit(tabId)
|
||||
|
|
@ -1383,13 +1432,26 @@ export default function TerminalPane({
|
|||
onEqualizePaneSizes={contextMenu.onEqualizePaneSizes}
|
||||
onClosePane={contextMenu.onClosePane}
|
||||
onClearScreen={contextMenu.onClearScreen}
|
||||
quickCommands={(settings?.terminalQuickCommands ?? []).filter(
|
||||
(command) => command.label.trim() && command.command.trimEnd()
|
||||
)}
|
||||
repoQuickCommands={repoQuickCommands}
|
||||
globalQuickCommands={globalQuickCommands}
|
||||
quickCommandRepoLabel={quickCommandRepoLabel}
|
||||
onQuickCommand={contextMenu.onQuickCommand}
|
||||
onAddQuickCommand={
|
||||
quickCommandRepoId
|
||||
? () => openQuickCommandEditor({ type: 'repo', repoId: quickCommandRepoId })
|
||||
: () => openQuickCommandEditor({ type: 'global' })
|
||||
}
|
||||
onToggleExpand={contextMenu.onToggleExpand}
|
||||
onSetTitle={contextMenu.onSetTitle}
|
||||
/>
|
||||
<TerminalQuickCommandDialog
|
||||
open={quickCommandEditorOpen}
|
||||
mode="add"
|
||||
command={quickCommandDraft}
|
||||
repos={repos}
|
||||
onOpenChange={setQuickCommandEditorOpen}
|
||||
onSave={saveQuickCommand}
|
||||
/>
|
||||
{/* Title bar overlays — portaled into each pane container that has a title
|
||||
or is currently being renamed (so the inline input appears even for
|
||||
untitled panes when "Set Title..." is triggered).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import type {
|
||||
Repo,
|
||||
TerminalQuickCommand,
|
||||
TerminalQuickCommandScope
|
||||
} from '../../../../shared/types'
|
||||
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
||||
|
||||
type TerminalQuickCommandDialogMode = 'add' | 'edit'
|
||||
|
||||
type TerminalQuickCommandDialogProps = {
|
||||
open: boolean
|
||||
mode: TerminalQuickCommandDialogMode
|
||||
command: TerminalQuickCommand
|
||||
repos?: Pick<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (command: TerminalQuickCommand) => void
|
||||
}
|
||||
|
||||
export function createTerminalQuickCommandDraft(
|
||||
scope: TerminalQuickCommandScope = { type: 'global' }
|
||||
): TerminalQuickCommand {
|
||||
return {
|
||||
id: `quick-command-${createBrowserUuid()}`,
|
||||
label: '',
|
||||
command: '',
|
||||
appendEnter: true,
|
||||
scope
|
||||
}
|
||||
}
|
||||
|
||||
function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string {
|
||||
return repo.displayName || repo.path
|
||||
}
|
||||
|
||||
export function TerminalQuickCommandDialog({
|
||||
open,
|
||||
mode,
|
||||
command,
|
||||
repos = [],
|
||||
onOpenChange,
|
||||
onSave
|
||||
}: TerminalQuickCommandDialogProps): React.JSX.Element {
|
||||
const [draft, setDraft] = useState<TerminalQuickCommand>(command)
|
||||
const selectedScope = getTerminalQuickCommandScope(draft)
|
||||
// Why: repo-scoped commands can outlive the current repo list; only an
|
||||
// explicit selection should replace the saved repo id.
|
||||
const selectedRepo =
|
||||
selectedScope.type === 'repo'
|
||||
? (repos.find((repo) => repo.id === selectedScope.repoId) ?? null)
|
||||
: null
|
||||
const selectedRepoId = selectedRepo?.id ?? ''
|
||||
const selectedRepoMissing = selectedScope.type === 'repo' && selectedRepo === null
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft({ ...command })
|
||||
}
|
||||
}, [command, open])
|
||||
|
||||
const saveDraft = (): void => {
|
||||
const next = {
|
||||
...draft,
|
||||
label: draft.label.trim(),
|
||||
command: draft.command.trimEnd(),
|
||||
scope: selectedScope
|
||||
}
|
||||
if (!next.label || !next.command) {
|
||||
return
|
||||
}
|
||||
onSave(next)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const canSave = draft.label.trim().length > 0 && draft.command.trimEnd().length > 0
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md sm:max-w-md" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{mode === 'edit' ? 'Edit Quick Command' : 'Add Quick Command'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Save terminal input text for the context menu.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
value={draft.label}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, label: event.target.value }))
|
||||
}
|
||||
placeholder="Restart server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Command Text</Label>
|
||||
<textarea
|
||||
value={draft.command}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, command: event.target.value }))
|
||||
}
|
||||
placeholder="npm run dev"
|
||||
rows={4}
|
||||
className="min-h-24 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Scope</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={selectedScope.type}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'global') {
|
||||
setDraft((current) => ({ ...current, scope: { type: 'global' } }))
|
||||
}
|
||||
if (value === 'repo' && repos[0]) {
|
||||
if (selectedScope.type !== 'repo') {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
scope: { type: 'repo', repoId: repos[0].id }
|
||||
}))
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="justify-start"
|
||||
>
|
||||
<ToggleGroupItem value="global">Global</ToggleGroupItem>
|
||||
<ToggleGroupItem value="repo" disabled={repos.length === 0}>
|
||||
Repository
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{selectedScope.type === 'repo' && repos.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<Select
|
||||
value={selectedRepoId}
|
||||
onValueChange={(repoId) =>
|
||||
setDraft((current) => ({ ...current, scope: { type: 'repo', repoId } }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="min-w-48">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
selectedRepoMissing ? 'Repository not in list' : 'Choose repository'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repos.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
<RepoDotLabel
|
||||
name={getRepoLabel(repo)}
|
||||
color={repo.badgeColor}
|
||||
className="max-w-full"
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedRepoMissing ? (
|
||||
<p className="max-w-48 text-xs text-muted-foreground">
|
||||
Saving keeps the existing repo scope unless you choose another.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border border-border/50 px-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">Append Enter</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Submit immediately instead of only inserting text.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={draft.appendEnter}
|
||||
aria-label="Toggle append Enter"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({ ...current, appendEnter: !current.appendEnter }))
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
draft.appendEnter ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
draft.appendEnter ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={saveDraft} disabled={!canSave}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|||
import {
|
||||
buildTerminalQuickCommandInput,
|
||||
getDefaultTerminalQuickCommands,
|
||||
normalizeTerminalQuickCommands
|
||||
normalizeTerminalQuickCommands,
|
||||
terminalQuickCommandMatchesRepo
|
||||
} from './terminal-quick-commands'
|
||||
|
||||
describe('terminal quick commands', () => {
|
||||
|
|
@ -48,29 +49,106 @@ describe('terminal quick commands', () => {
|
|||
id: 'status',
|
||||
label: 'Status',
|
||||
command: 'git status',
|
||||
appendEnter: false
|
||||
appendEnter: false,
|
||||
scope: { type: 'global' }
|
||||
},
|
||||
{
|
||||
id: 'empty-command',
|
||||
label: 'Empty',
|
||||
command: '',
|
||||
appendEnter: true
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
},
|
||||
{
|
||||
id: 'status-2',
|
||||
label: 'Duplicate',
|
||||
command: 'pwd',
|
||||
appendEnter: true
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
},
|
||||
{
|
||||
id: 'quick-command-4',
|
||||
label: 'No ID',
|
||||
command: 'date',
|
||||
appendEnter: true
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes repository scoped commands and falls back to global for invalid scopes', () => {
|
||||
expect(
|
||||
normalizeTerminalQuickCommands([
|
||||
{
|
||||
id: 'repo-dev',
|
||||
label: 'Dev',
|
||||
command: 'pnpm dev',
|
||||
scope: { type: 'repo', repoId: ' repo-1 ' }
|
||||
},
|
||||
{
|
||||
id: 'bad-repo',
|
||||
label: 'Bad',
|
||||
command: 'echo bad',
|
||||
scope: { type: 'repo', repoId: ' ' }
|
||||
}
|
||||
])
|
||||
).toEqual([
|
||||
{
|
||||
id: 'repo-dev',
|
||||
label: 'Dev',
|
||||
command: 'pnpm dev',
|
||||
appendEnter: true,
|
||||
scope: { type: 'repo', repoId: 'repo-1' }
|
||||
},
|
||||
{
|
||||
id: 'bad-repo',
|
||||
label: 'Bad',
|
||||
command: 'echo bad',
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('matches global commands everywhere and repo commands only in their repo', () => {
|
||||
expect(
|
||||
terminalQuickCommandMatchesRepo(
|
||||
{
|
||||
id: 'global',
|
||||
label: 'Global',
|
||||
command: 'date',
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
},
|
||||
null
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
terminalQuickCommandMatchesRepo(
|
||||
{
|
||||
id: 'repo',
|
||||
label: 'Repo',
|
||||
command: 'pnpm dev',
|
||||
appendEnter: true,
|
||||
scope: { type: 'repo', repoId: 'repo-1' }
|
||||
},
|
||||
'repo-1'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
terminalQuickCommandMatchesRepo(
|
||||
{
|
||||
id: 'repo',
|
||||
label: 'Repo',
|
||||
command: 'pnpm dev',
|
||||
appendEnter: true,
|
||||
scope: { type: 'repo', repoId: 'repo-1' }
|
||||
},
|
||||
'repo-2'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('formats terminal input without assuming shell semantics', () => {
|
||||
expect(
|
||||
buildTerminalQuickCommandInput({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { TerminalQuickCommand } from './types'
|
||||
import type { TerminalQuickCommand, TerminalQuickCommandScope } from './types'
|
||||
|
||||
const MAX_QUICK_COMMANDS = 40
|
||||
const MAX_QUICK_COMMAND_LABEL_LENGTH = 80
|
||||
const MAX_QUICK_COMMAND_REPO_ID_LENGTH = 200
|
||||
const MAX_QUICK_COMMAND_TEXT_LENGTH = 4000
|
||||
const REMOVED_PRESET_IDS = new Set(['default-pwd', 'default-git-status'])
|
||||
|
||||
|
|
@ -11,6 +12,35 @@ export function getDefaultTerminalQuickCommands(): TerminalQuickCommand[] {
|
|||
return DEFAULT_TERMINAL_QUICK_COMMANDS.map((command) => ({ ...command }))
|
||||
}
|
||||
|
||||
function normalizeTerminalQuickCommandScope(input: unknown): TerminalQuickCommandScope {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return { type: 'global' }
|
||||
}
|
||||
const record = input as Record<string, unknown>
|
||||
if (record.type !== 'repo') {
|
||||
return { type: 'global' }
|
||||
}
|
||||
const repoId = typeof record.repoId === 'string' ? record.repoId.trim() : ''
|
||||
if (!repoId) {
|
||||
return { type: 'global' }
|
||||
}
|
||||
return { type: 'repo', repoId: repoId.slice(0, MAX_QUICK_COMMAND_REPO_ID_LENGTH) }
|
||||
}
|
||||
|
||||
export function getTerminalQuickCommandScope(
|
||||
command: TerminalQuickCommand
|
||||
): TerminalQuickCommandScope {
|
||||
return normalizeTerminalQuickCommandScope(command.scope)
|
||||
}
|
||||
|
||||
export function terminalQuickCommandMatchesRepo(
|
||||
command: TerminalQuickCommand,
|
||||
repoId: string | null
|
||||
): boolean {
|
||||
const scope = getTerminalQuickCommandScope(command)
|
||||
return scope.type === 'global' || (repoId !== null && scope.repoId === repoId)
|
||||
}
|
||||
|
||||
export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCommand[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return getDefaultTerminalQuickCommands()
|
||||
|
|
@ -51,7 +81,8 @@ export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCom
|
|||
id,
|
||||
label: label.slice(0, MAX_QUICK_COMMAND_LABEL_LENGTH),
|
||||
command: command.slice(0, MAX_QUICK_COMMAND_TEXT_LENGTH),
|
||||
appendEnter: record.appendEnter !== false
|
||||
appendEnter: record.appendEnter !== false,
|
||||
scope: normalizeTerminalQuickCommandScope(record.scope)
|
||||
})
|
||||
|
||||
if (normalized.length >= MAX_QUICK_COMMANDS) {
|
||||
|
|
|
|||
|
|
@ -1333,11 +1333,21 @@ export type TerminalColorOverrides = {
|
|||
bold?: string
|
||||
}
|
||||
|
||||
export type TerminalQuickCommandScope =
|
||||
| {
|
||||
type: 'global'
|
||||
}
|
||||
| {
|
||||
type: 'repo'
|
||||
repoId: string
|
||||
}
|
||||
|
||||
export type TerminalQuickCommand = {
|
||||
id: string
|
||||
label: string
|
||||
command: string
|
||||
appendEnter: boolean
|
||||
scope?: TerminalQuickCommandScope
|
||||
}
|
||||
|
||||
export type OpenInApplication = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue