Add Quick Commands surface with tab-bar split-button and settings pane (#2650)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
d3d63c888b
commit
114619c8c8
|
|
@ -0,0 +1,367 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { Check, ChevronsUpDown, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import type {
|
||||
GlobalSettings,
|
||||
Repo,
|
||||
TerminalQuickCommand,
|
||||
TerminalQuickCommandScope
|
||||
} from '../../../../shared/types'
|
||||
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
|
||||
import {
|
||||
createTerminalQuickCommandDraft,
|
||||
TerminalQuickCommandDialog
|
||||
} from '@/components/terminal-quick-commands/TerminalQuickCommandDialog'
|
||||
import { useAppStore } from '../../store'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { Button } from '../ui/button'
|
||||
import { Command, CommandItem, CommandList } from '../ui/command'
|
||||
import { Label } from '../ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
|
||||
import RepoDotLabel from '../repo/RepoDotLabel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog'
|
||||
|
||||
type QuickCommandsPaneProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
}
|
||||
|
||||
const GLOBAL_SCOPE_KEY = '__global__'
|
||||
|
||||
type EditorState =
|
||||
| {
|
||||
mode: 'add'
|
||||
command: TerminalQuickCommand
|
||||
}
|
||||
| {
|
||||
mode: 'edit'
|
||||
command: TerminalQuickCommand
|
||||
}
|
||||
| null
|
||||
|
||||
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 project'
|
||||
}
|
||||
|
||||
export function QuickCommandsPane({
|
||||
settings,
|
||||
updateSettings
|
||||
}: QuickCommandsPaneProps): React.JSX.Element {
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const commands = settings.terminalQuickCommands ?? []
|
||||
const confirm = useConfirmationDialog()
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>(null)
|
||||
// Why: `null` means "show all" (sticky-all), independent of the current repo
|
||||
// list — mirrors the tasks-page repo combobox so newly added repos appear
|
||||
// automatically rather than being silently excluded.
|
||||
const [scopeSelection, setScopeSelection] = useState<ReadonlySet<string> | null>(null)
|
||||
const [scopePopoverOpen, setScopePopoverOpen] = useState(false)
|
||||
|
||||
const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
|
||||
|
||||
const allScopeKeys = useMemo(
|
||||
() => new Set<string>([GLOBAL_SCOPE_KEY, ...repos.map((r) => r.id)]),
|
||||
[repos]
|
||||
)
|
||||
const effectiveSelection: ReadonlySet<string> = scopeSelection ?? allScopeKeys
|
||||
const showAll = scopeSelection === null
|
||||
|
||||
const visibleCommands = commands.filter((command) => {
|
||||
const scope = getTerminalQuickCommandScope(command)
|
||||
if (showAll) {
|
||||
return true
|
||||
}
|
||||
if (scope.type === 'global') {
|
||||
return effectiveSelection.has(GLOBAL_SCOPE_KEY)
|
||||
}
|
||||
return effectiveSelection.has(scope.repoId)
|
||||
})
|
||||
|
||||
const createDraftForCurrentFilter = (): TerminalQuickCommand => {
|
||||
// Why: when the user has narrowed to a single repo scope, the natural
|
||||
// intent for "Add Command" is to create one in that repo. When the filter
|
||||
// is narrowed to Global-only, honor that. Otherwise prefer the active
|
||||
// workspace repo; fall back to global when there's no active repo.
|
||||
if (!showAll) {
|
||||
const selectedRepoIds = [...effectiveSelection].filter((key) => key !== GLOBAL_SCOPE_KEY)
|
||||
if (selectedRepoIds.length === 1 && !effectiveSelection.has(GLOBAL_SCOPE_KEY)) {
|
||||
return createTerminalQuickCommandDraft({ type: 'repo', repoId: selectedRepoIds[0] })
|
||||
}
|
||||
if (selectedRepoIds.length === 0 && effectiveSelection.has(GLOBAL_SCOPE_KEY)) {
|
||||
return createTerminalQuickCommandDraft({ type: 'global' })
|
||||
}
|
||||
}
|
||||
if (activeRepoId && repoById.has(activeRepoId)) {
|
||||
return createTerminalQuickCommandDraft({ type: 'repo', repoId: activeRepoId })
|
||||
}
|
||||
return createTerminalQuickCommandDraft({ type: 'global' })
|
||||
}
|
||||
|
||||
const toggleScope = (key: string): void => {
|
||||
const current = new Set(effectiveSelection)
|
||||
if (current.has(key)) {
|
||||
// Why: forbid the empty selection — every command would disappear and
|
||||
// there'd be no signal that the filter caused it.
|
||||
if (current.size <= 1) {
|
||||
return
|
||||
}
|
||||
current.delete(key)
|
||||
} else {
|
||||
current.add(key)
|
||||
}
|
||||
setScopeSelection(current.size === allScopeKeys.size ? null : current)
|
||||
}
|
||||
|
||||
const handleSelectAll = (): void => {
|
||||
if (showAll) {
|
||||
// Why: tasks-page parity — clicking "All" while everything is selected
|
||||
// collapses to a single scope rather than emitting an empty set.
|
||||
setScopeSelection(new Set([GLOBAL_SCOPE_KEY]))
|
||||
return
|
||||
}
|
||||
setScopeSelection(null)
|
||||
}
|
||||
|
||||
const renderTriggerLabel = (): React.JSX.Element => {
|
||||
if (showAll) {
|
||||
return <span>All commands</span>
|
||||
}
|
||||
const includesGlobal = effectiveSelection.has(GLOBAL_SCOPE_KEY)
|
||||
const selectedRepos = repos.filter((r) => effectiveSelection.has(r.id))
|
||||
const parts: string[] = []
|
||||
if (includesGlobal) {
|
||||
parts.push('Global')
|
||||
}
|
||||
if (selectedRepos.length > 0) {
|
||||
const [first, ...rest] = selectedRepos
|
||||
parts.push(rest.length > 0 ? `${first.displayName} +${rest.length}` : first.displayName)
|
||||
}
|
||||
return <span className="truncate">{parts.join(', ') || 'None'}</span>
|
||||
}
|
||||
|
||||
const saveCommand = (next: TerminalQuickCommand): void => {
|
||||
// Why: re-read from the store so save lands on the latest list when
|
||||
// multiple edit dialogs fire in quick succession.
|
||||
const latest = useAppStore.getState().settings?.terminalQuickCommands ?? []
|
||||
const isEdit = latest.some((command) => command.id === next.id)
|
||||
const nextList = isEdit
|
||||
? latest.map((command) => (command.id === next.id ? next : command))
|
||||
: [...latest, next]
|
||||
updateSettings({ terminalQuickCommands: nextList })
|
||||
}
|
||||
|
||||
const removeCommand = async (command: TerminalQuickCommand): Promise<void> => {
|
||||
const confirmed = await confirm({
|
||||
title: `Delete "${command.label || 'Untitled'}"?`,
|
||||
description: 'This quick command will be removed from your saved list.',
|
||||
confirmLabel: 'Delete',
|
||||
confirmVariant: 'destructive'
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
// Why: re-read latest list from the store at delete time — the await above
|
||||
// can span other settings changes, and a stale closure would resurrect
|
||||
// commands that were removed concurrently.
|
||||
const latest = useAppStore.getState().settings?.terminalQuickCommands ?? []
|
||||
updateSettings({
|
||||
terminalQuickCommands: latest.filter((c) => c.id !== command.id)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Saved Commands</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run them from the Quick Commands button in the tab bar, or right-click inside any
|
||||
terminal.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditor({ mode: 'add', command: createDraftForCurrentFilter() })}
|
||||
>
|
||||
<Plus />
|
||||
Add Command
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Popover open={scopePopoverOpen} onOpenChange={setScopePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={scopePopoverOpen}
|
||||
className="h-8 min-w-52 justify-between px-3 text-xs font-normal"
|
||||
>
|
||||
{renderTriggerLabel()}
|
||||
<ChevronsUpDown className="size-3.5 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[min(320px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0"
|
||||
>
|
||||
<Command>
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
showAll && 'opacity-80'
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground',
|
||||
showAll ? 'opacity-70' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span>All commands</span>
|
||||
</button>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandItem
|
||||
value={GLOBAL_SCOPE_KEY}
|
||||
onSelect={() => toggleScope(GLOBAL_SCOPE_KEY)}
|
||||
className="items-center gap-2 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground',
|
||||
effectiveSelection.has(GLOBAL_SCOPE_KEY) ? 'opacity-70' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span>Global</span>
|
||||
</CommandItem>
|
||||
{repos.map((repo) => {
|
||||
const isSelected = effectiveSelection.has(repo.id)
|
||||
return (
|
||||
<CommandItem
|
||||
key={repo.id}
|
||||
value={repo.id}
|
||||
onSelect={() => toggleScope(repo.id)}
|
||||
className="items-center gap-2 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground',
|
||||
isSelected ? 'opacity-70' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<RepoDotLabel
|
||||
name={getRepoLabel(repo)}
|
||||
color={repo.badgeColor}
|
||||
className="max-w-full"
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border/50">
|
||||
{visibleCommands.length === 0 ? (
|
||||
<div className="px-3 py-6 text-sm text-muted-foreground">
|
||||
{commands.length === 0
|
||||
? 'No quick commands saved.'
|
||||
: 'No commands in the selected scopes.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[60vh] divide-y divide-border/50 overflow-y-auto scrollbar-sleek">
|
||||
{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 gap-1.5">
|
||||
{scope.type === 'repo' ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-1.5 shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: repoById.get(scope.repoId)?.badgeColor
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{getScopeLabel(scope, repoById)}</span>
|
||||
</>
|
||||
) : (
|
||||
<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={() => void removeCommand(command)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editor !== null ? (
|
||||
<TerminalQuickCommandDialog
|
||||
open
|
||||
mode={editor.mode}
|
||||
command={editor.command}
|
||||
repos={repos}
|
||||
onOpenChange={(open) => !open && setEditor(null)}
|
||||
onSave={saveCommand}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
MousePointerClick,
|
||||
Network,
|
||||
PanelsTopLeft,
|
||||
Play,
|
||||
ShieldCheck,
|
||||
Palette,
|
||||
Server,
|
||||
|
|
@ -65,6 +66,8 @@ import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane'
|
|||
import { IntegrationsPane, INTEGRATIONS_PANE_SEARCH_ENTRIES } from './IntegrationsPane'
|
||||
import { TasksPane } from './TasksPane'
|
||||
import { TASKS_PANE_SEARCH_ENTRIES } from './tasks-search'
|
||||
import { QuickCommandsPane } from './QuickCommandsPane'
|
||||
import { QUICK_COMMANDS_PANE_SEARCH_ENTRIES } from './quick-commands-search'
|
||||
import {
|
||||
DeveloperPermissionsPane,
|
||||
DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES
|
||||
|
|
@ -103,6 +106,7 @@ type SettingsNavTarget =
|
|||
| 'input'
|
||||
| 'floating-workspace'
|
||||
| 'terminal'
|
||||
| 'quick-commands'
|
||||
| 'notifications'
|
||||
| 'computer-use'
|
||||
| 'developer-permissions'
|
||||
|
|
@ -520,11 +524,19 @@ function Settings(): React.JSX.Element {
|
|||
{
|
||||
id: 'terminal',
|
||||
title: 'Terminal',
|
||||
description: 'Shells, terminal appearance, quick commands, and pane behavior.',
|
||||
description: 'Shells, terminal appearance, and pane behavior.',
|
||||
icon: SquareTerminal,
|
||||
searchEntries: terminalPaneSearchEntries,
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'quick-commands',
|
||||
title: 'Quick Commands',
|
||||
description: 'Saved terminal commands, scoped globally or per project.',
|
||||
icon: Play,
|
||||
searchEntries: QUICK_COMMANDS_PANE_SEARCH_ENTRIES,
|
||||
group: 'workflows'
|
||||
},
|
||||
...(showDesktopOnlySettings
|
||||
? [
|
||||
{
|
||||
|
|
@ -1075,7 +1087,7 @@ function Settings(): React.JSX.Element {
|
|||
<SettingsSection
|
||||
id="terminal"
|
||||
title="Terminal"
|
||||
description="Shells, terminal appearance, quick commands, and pane behavior."
|
||||
description="Shells, terminal appearance, and pane behavior."
|
||||
searchEntries={terminalPaneSearchEntries}
|
||||
headerAction={
|
||||
<Button
|
||||
|
|
@ -1106,6 +1118,17 @@ function Settings(): React.JSX.Element {
|
|||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="quick-commands"
|
||||
title="Quick Commands"
|
||||
description="Saved terminal commands, scoped globally or per project."
|
||||
searchEntries={QUICK_COMMANDS_PANE_SEARCH_ENTRIES}
|
||||
>
|
||||
{isSectionMounted('quick-commands') ? (
|
||||
<QuickCommandsPane settings={settings} updateSettings={updateSettings} />
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
{showDesktopOnlySettings ? (
|
||||
<SettingsSection
|
||||
id="browser"
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ import {
|
|||
TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
|
||||
TERMINAL_MAC_OPTION_SEARCH_ENTRIES,
|
||||
TERMINAL_PANE_STYLE_SEARCH_ENTRIES,
|
||||
TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES,
|
||||
TERMINAL_RENDERING_SEARCH_ENTRIES,
|
||||
TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES,
|
||||
TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES,
|
||||
|
|
@ -57,8 +56,6 @@ import { TerminalWindowSection } from './TerminalWindowSection'
|
|||
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
|
||||
|
|
@ -89,9 +86,6 @@ 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('')
|
||||
|
|
@ -167,39 +161,6 @@ export function TerminalPane({
|
|||
</SearchableSetting>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES) ? (
|
||||
<section key="quick-commands" className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Quick Commands</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Save global and repository-specific terminal snippets for the right-click menu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title="Quick Commands"
|
||||
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>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES) ? (
|
||||
<section key="typography" className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
|
|
|
|||
|
|
@ -1,248 +0,0 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import type {
|
||||
Repo,
|
||||
TerminalQuickCommand,
|
||||
TerminalQuickCommandScope
|
||||
} from '../../../../shared/types'
|
||||
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
|
||||
import {
|
||||
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'
|
||||
command: TerminalQuickCommand
|
||||
}
|
||||
| {
|
||||
mode: 'edit'
|
||||
command: TerminalQuickCommand
|
||||
}
|
||||
| null
|
||||
|
||||
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 [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 (!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])
|
||||
}
|
||||
}
|
||||
|
||||
const removeCommand = (id: string): void => {
|
||||
onChange(commands.filter((command) => command.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Saved Commands</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Commands are sent as plain terminal input to the active pane.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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">
|
||||
{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">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TerminalQuickCommandDialog
|
||||
open={editor !== null}
|
||||
mode={editor?.mode ?? 'add'}
|
||||
command={editor?.command ?? createTerminalQuickCommandDraft()}
|
||||
repos={repos}
|
||||
onOpenChange={(open) => !open && setEditor(null)}
|
||||
onSave={saveCommand}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const QUICK_COMMANDS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Quick Commands',
|
||||
description:
|
||||
'Saved terminal commands that can be launched from any terminal, scoped globally or to a specific project.',
|
||||
keywords: [
|
||||
'quick',
|
||||
'command',
|
||||
'commands',
|
||||
'terminal',
|
||||
'shortcut',
|
||||
'snippet',
|
||||
'global',
|
||||
'project',
|
||||
'repo',
|
||||
'repository',
|
||||
'run',
|
||||
'launch',
|
||||
'pnpm',
|
||||
'npm',
|
||||
'yarn'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -78,14 +78,6 @@ export const TERMINAL_CURSOR_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
|||
}
|
||||
]
|
||||
|
||||
export const TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Quick Commands',
|
||||
description: 'Saved terminal command snippets available from the terminal right-click menu.',
|
||||
keywords: ['terminal', 'command', 'snippet', 'quick command', 'send', 'context menu']
|
||||
}
|
||||
]
|
||||
|
||||
export const TERMINAL_PANE_STYLE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Inactive Pane Opacity',
|
||||
|
|
@ -283,7 +275,6 @@ export function getTerminalPaneSearchEntries(platform: {
|
|||
// users from landing on an option the UI intentionally hides.
|
||||
return [
|
||||
...TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES,
|
||||
...TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES,
|
||||
...TERMINAL_RENDERING_SEARCH_ENTRIES,
|
||||
...TERMINAL_CURSOR_SEARCH_ENTRIES,
|
||||
...TERMINAL_PANE_STYLE_SEARCH_ENTRIES,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,309 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { ChevronDown, Pencil, Play, Plus, Trash2 } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
createTerminalQuickCommandDraft,
|
||||
TerminalQuickCommandDialog
|
||||
} from '@/components/terminal-quick-commands/TerminalQuickCommandDialog'
|
||||
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab'
|
||||
import type { TerminalQuickCommand } from '../../../../shared/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog'
|
||||
|
||||
type TabBarQuickCommandsButtonProps = {
|
||||
worktreeId: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export function TabBarQuickCommandsButton({
|
||||
worktreeId,
|
||||
groupId
|
||||
}: TabBarQuickCommandsButtonProps): React.JSX.Element | null {
|
||||
const allCommands = useAppStore((s) => s.settings?.terminalQuickCommands)
|
||||
const recentByGroup = useAppStore((s) => s.recentQuickCommandIdByGroup)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const confirm = useConfirmationDialog()
|
||||
// Why: floating terminals share a synthetic worktree id (`global-floating-terminal`)
|
||||
// that has no separator, so naive `getRepoIdFromWorktreeId` would return that
|
||||
// sentinel as a "repo id" and the button would point at a repo that doesn't
|
||||
// exist. Resolve to a real repo from the workspace; otherwise hide the button.
|
||||
const repoId = useMemo(() => {
|
||||
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return null
|
||||
}
|
||||
const candidate = getRepoIdFromWorktreeId(worktreeId)
|
||||
return repos.some((r) => r.id === candidate) ? candidate : null
|
||||
}, [worktreeId, repos])
|
||||
|
||||
const { repoCommands, globalCommands } = useMemo(() => {
|
||||
const repoList: TerminalQuickCommand[] = []
|
||||
const globalList: TerminalQuickCommand[] = []
|
||||
for (const command of allCommands ?? []) {
|
||||
if (!command.label.trim() || !command.command.trimEnd()) {
|
||||
continue
|
||||
}
|
||||
const scope = getTerminalQuickCommandScope(command)
|
||||
if (scope.type === 'global') {
|
||||
globalList.push(command)
|
||||
} else if (scope.type === 'repo' && repoId !== null && scope.repoId === repoId) {
|
||||
repoList.push(command)
|
||||
}
|
||||
}
|
||||
return { repoCommands: repoList, globalCommands: globalList }
|
||||
}, [allCommands, repoId])
|
||||
|
||||
const recentId = recentByGroup[groupId] ?? null
|
||||
// Why: split-button label prefers the most recently used command for this
|
||||
// group regardless of scope, then falls back to the first repo command (so
|
||||
// repo-scoped is preferred over global on first run), then to the first
|
||||
// global one if no repo commands exist.
|
||||
const mostRecent = useMemo(() => {
|
||||
if (recentId) {
|
||||
const match =
|
||||
repoCommands.find((c) => c.id === recentId) ?? globalCommands.find((c) => c.id === recentId)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return repoCommands[0] ?? globalCommands[0] ?? null
|
||||
}, [repoCommands, globalCommands, recentId])
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [commandValue, setCommandValue] = useState('')
|
||||
const [editor, setEditor] = useState<
|
||||
| { mode: 'add'; command: TerminalQuickCommand }
|
||||
| { mode: 'edit'; command: TerminalQuickCommand }
|
||||
| null
|
||||
>(null)
|
||||
|
||||
const totalVisible = repoCommands.length + globalCommands.length
|
||||
const hasAnyCommands = totalVisible > 0
|
||||
|
||||
const handleOpenChange = (next: boolean): void => {
|
||||
setMenuOpen(next)
|
||||
if (!next) {
|
||||
setCommandValue('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRun = (command: TerminalQuickCommand): void => {
|
||||
setMenuOpen(false)
|
||||
runQuickCommandInNewTab({ command, worktreeId, groupId })
|
||||
}
|
||||
|
||||
const handleSaveCommand = (next: TerminalQuickCommand): void => {
|
||||
const current = useAppStore.getState().settings?.terminalQuickCommands ?? []
|
||||
const isEdit = current.some((c) => c.id === next.id)
|
||||
const nextList = isEdit ? current.map((c) => (c.id === next.id ? next : c)) : [...current, next]
|
||||
void updateSettings({ terminalQuickCommands: nextList })
|
||||
}
|
||||
|
||||
const handleDeleteCommand = async (command: TerminalQuickCommand): Promise<void> => {
|
||||
setMenuOpen(false)
|
||||
const confirmed = await confirm({
|
||||
title: `Delete "${command.label}"?`,
|
||||
description: 'This quick command will be removed from your saved list.',
|
||||
confirmLabel: 'Delete',
|
||||
confirmVariant: 'destructive'
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
const current = useAppStore.getState().settings?.terminalQuickCommands ?? []
|
||||
void updateSettings({ terminalQuickCommands: current.filter((c) => c.id !== command.id) })
|
||||
}
|
||||
|
||||
// Why: hidden in folder-mode worktrees (no repoId) and floating terminals.
|
||||
// Without a repoId the button can't represent a repo-scoped run target, and
|
||||
// global-only mode would be confusing in a context that doesn't belong to a
|
||||
// repo at all.
|
||||
if (!repoId) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Empty state: single "Add command" button that opens the dialog directly.
|
||||
if (!hasAnyCommands) {
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setEditor({
|
||||
mode: 'add',
|
||||
command: createTerminalQuickCommandDraft({ type: 'repo', repoId })
|
||||
})
|
||||
}
|
||||
className="my-auto flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
aria-label="Add quick command"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
<span className="text-[12px] font-medium">Add command</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Save a quick command for this repo
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<TerminalQuickCommandDialog
|
||||
open={editor !== null}
|
||||
mode={editor?.mode ?? 'add'}
|
||||
command={editor?.command ?? createTerminalQuickCommandDraft({ type: 'repo', repoId })}
|
||||
repos={repos}
|
||||
onOpenChange={(open) => !open && setEditor(null)}
|
||||
onSave={handleSaveCommand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const splitButtonClass =
|
||||
'my-auto flex h-7 shrink-0 items-stretch overflow-hidden rounded-md border border-border/60 text-muted-foreground'
|
||||
const innerButtonBase =
|
||||
'flex items-center bg-transparent leading-none text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent'
|
||||
|
||||
const renderItem = (command: TerminalQuickCommand): React.JSX.Element => (
|
||||
<CommandItem
|
||||
key={command.id}
|
||||
value={command.id}
|
||||
onSelect={() => handleRun(command)}
|
||||
className="group/qc mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 data-[selected=true]:bg-black/8 dark:data-[selected=true]:bg-white/14"
|
||||
>
|
||||
<Play className="size-3 shrink-0 text-muted-foreground" fill="currentColor" strokeWidth={0} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-foreground">{command.label}</span>
|
||||
<span className="block truncate font-mono text-[11px] text-muted-foreground">
|
||||
{command.command}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover/qc:opacity-100 group-data-[selected=true]/qc:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
setEditor({ mode: 'edit', command })
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={`Edit ${command.label}`}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void handleDeleteCommand(command)
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
aria-label={`Remove ${command.label}`}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={splitButtonClass}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mostRecent && handleRun(mostRecent)}
|
||||
disabled={!mostRecent}
|
||||
className={cn(innerButtonBase, 'gap-1.5 rounded-l-md rounded-r-none px-1.5')}
|
||||
aria-label={
|
||||
mostRecent ? `Run quick command: ${mostRecent.label}` : 'Run quick command'
|
||||
}
|
||||
>
|
||||
<Play className="size-3 shrink-0" fill="currentColor" strokeWidth={0} />
|
||||
<span className="max-w-[160px] truncate text-[12px] font-medium">
|
||||
{mostRecent?.label ?? 'Run'}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{mostRecent ? `Run: ${mostRecent.command}` : 'Run quick command'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
innerButtonBase,
|
||||
'justify-center rounded-l-none rounded-r-md border-l border-border/60 px-1'
|
||||
)}
|
||||
aria-label="More quick commands"
|
||||
>
|
||||
<ChevronDown className="size-3" strokeWidth={2.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" sideOffset={6} className="w-72 p-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
value={commandValue}
|
||||
onValueChange={setCommandValue}
|
||||
className="bg-transparent"
|
||||
>
|
||||
<CommandList className="max-h-72 py-1">
|
||||
{totalVisible === 0 ? (
|
||||
<CommandEmpty className="py-4 text-center text-[11px]">No commands</CommandEmpty>
|
||||
) : null}
|
||||
{repoCommands.map(renderItem)}
|
||||
{repoCommands.length > 0 && globalCommands.length > 0 ? (
|
||||
<CommandSeparator className="my-1" />
|
||||
) : null}
|
||||
{globalCommands.map(renderItem)}
|
||||
</CommandList>
|
||||
<div className="border-t border-border/50 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setEditor({
|
||||
mode: 'add',
|
||||
command: createTerminalQuickCommandDraft({ type: 'repo', repoId })
|
||||
})
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add command
|
||||
</button>
|
||||
</div>
|
||||
</Command>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TerminalQuickCommandDialog
|
||||
open={editor !== null}
|
||||
mode={editor?.mode ?? 'add'}
|
||||
command={editor?.command ?? createTerminalQuickCommandDraft({ type: 'repo', repoId })}
|
||||
repos={repos}
|
||||
onOpenChange={(open) => !open && setEditor(null)}
|
||||
onSave={handleSaveCommand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import TabBar from '../tab-bar/TabBar'
|
||||
import { TabBarQuickCommandsButton } from '../tab-bar/TabBarQuickCommandsButton'
|
||||
import { useTabGroupWorkspaceModel } from './useTabGroupWorkspaceModel'
|
||||
import TabGroupDropOverlay from './TabGroupDropOverlay'
|
||||
import { resolveGroupTabFromVisibleId } from './tab-group-visible-id'
|
||||
|
|
@ -162,10 +163,13 @@ export default function TabGroupPanel({
|
|||
|
||||
const menuButtonClassName =
|
||||
'my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent'
|
||||
const actionChromeClassName = `flex shrink-0 items-center overflow-hidden transition-[width,margin,opacity] duration-150 ${
|
||||
isFocused
|
||||
? 'ml-1.5 w-7 pointer-events-auto opacity-100'
|
||||
: 'ml-1.5 w-7 pointer-events-none opacity-0'
|
||||
// Why: focused-only — the QC split-button and Pane Actions ellipsis both
|
||||
// appear together so the action cluster never reflows when focus shifts
|
||||
// between groups. Unfocused groups collapse the cluster fully (no
|
||||
// reserved width) since the surrounding tab strip already absorbs the
|
||||
// freed space.
|
||||
const actionChromeClassName = `flex shrink-0 items-center gap-0.5 overflow-hidden transition-[opacity] duration-150 ${
|
||||
isFocused ? 'ml-1.5 pointer-events-auto opacity-100' : 'pointer-events-none opacity-0 w-0'
|
||||
}`
|
||||
|
||||
return (
|
||||
|
|
@ -238,6 +242,9 @@ export default function TabGroupPanel({
|
|||
className={actionChromeClassName}
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{isFocused ? (
|
||||
<TabBarQuickCommandsButton worktreeId={worktreeId} groupId={groupId} />
|
||||
) : null}
|
||||
{isFocused ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
} from '@/components/ui/select'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
||||
import { isMacUserAgent } from '@/components/terminal-pane/pane-helpers'
|
||||
|
||||
type TerminalQuickCommandDialogMode = 'add' | 'edit'
|
||||
|
||||
|
|
@ -94,6 +95,7 @@ export function TerminalQuickCommandDialog({
|
|||
}
|
||||
|
||||
const canSave = draft.label.trim().length > 0 && draft.command.trimEnd().length > 0
|
||||
const submitShortcutLabel = isMacUserAgent() ? '⌘↵' : 'Ctrl+Enter'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -107,7 +109,18 @@ export function TerminalQuickCommandDialog({
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="space-y-4"
|
||||
onKeyDown={(event) => {
|
||||
// Why: cross-platform submit shortcut — Cmd+Enter on Mac, Ctrl+Enter
|
||||
// elsewhere. Falls through to native textarea/Input newline insertion
|
||||
// when the modifier isn't held.
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && canSave) {
|
||||
event.preventDefault()
|
||||
saveDraft()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
|
|
@ -115,7 +128,7 @@ export function TerminalQuickCommandDialog({
|
|||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, label: event.target.value }))
|
||||
}
|
||||
placeholder="Restart server"
|
||||
placeholder="Start dev server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -155,7 +168,7 @@ export function TerminalQuickCommandDialog({
|
|||
>
|
||||
<ToggleGroupItem value="global">Global</ToggleGroupItem>
|
||||
<ToggleGroupItem value="repo" disabled={repos.length === 0}>
|
||||
Repository
|
||||
Project
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{selectedScope.type === 'repo' && repos.length > 0 ? (
|
||||
|
|
@ -168,9 +181,7 @@ export function TerminalQuickCommandDialog({
|
|||
>
|
||||
<SelectTrigger size="sm" className="min-w-48">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
selectedRepoMissing ? 'Repository not in list' : 'Choose repository'
|
||||
}
|
||||
placeholder={selectedRepoMissing ? 'Project not in list' : 'Choose project'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -187,7 +198,7 @@ export function TerminalQuickCommandDialog({
|
|||
</Select>
|
||||
{selectedRepoMissing ? (
|
||||
<p className="max-w-48 text-xs text-muted-foreground">
|
||||
Saving keeps the existing repo scope unless you choose another.
|
||||
Saving keeps the existing project scope unless you choose another.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -227,8 +238,14 @@ export function TerminalQuickCommandDialog({
|
|||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={saveDraft} disabled={!canSave}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveDraft}
|
||||
disabled={!canSave}
|
||||
title={`Save (${submitShortcutLabel})`}
|
||||
>
|
||||
Save
|
||||
<span className="ml-1 text-[10px] opacity-60">{submitShortcutLabel}</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { useAppStore } from '@/store'
|
||||
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
|
||||
import type { TerminalQuickCommand } from '../../../shared/types'
|
||||
|
||||
export type RunQuickCommandInNewTabArgs = {
|
||||
command: TerminalQuickCommand
|
||||
worktreeId: string
|
||||
/** Tab group the user clicked from. Keeps the spawned terminal in the
|
||||
* pane the user initiated from instead of falling through to the active group. */
|
||||
groupId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a fresh terminal tab in the given group and queue the quick-command
|
||||
* text as the startup command. The PTY connection layer writes the command
|
||||
* once the shell is ready, so the user always sees their first prompt before
|
||||
* the command runs (mirrors the agent quick-launch path in
|
||||
* `launchAgentInNewTab`).
|
||||
*
|
||||
* Always appends Enter — the split-button is a "run" affordance, distinct
|
||||
* from the right-click "Insert" mode where `appendEnter: false` is honored.
|
||||
*/
|
||||
export function runQuickCommandInNewTab({
|
||||
command,
|
||||
worktreeId,
|
||||
groupId
|
||||
}: RunQuickCommandInNewTabArgs): { tabId: string } | null {
|
||||
// Why: a whitespace-only command would still spawn a terminal but feed it an
|
||||
// empty string, leaving the user with an unexplained blank tab. Refuse early.
|
||||
if (!command.command.trim()) {
|
||||
return null
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
const tab = store.createTab(worktreeId, groupId)
|
||||
|
||||
store.queueTabStartupCommand(tab.id, {
|
||||
command: command.command
|
||||
})
|
||||
|
||||
// Why: match `+` button's createNewTerminalTab — without this, a worktree
|
||||
// currently showing an editor file keeps rendering the editor and the new
|
||||
// terminal tab stays invisible.
|
||||
store.setActiveTabType('terminal')
|
||||
|
||||
// Why: persist tab-bar order with the new terminal appended. Without this,
|
||||
// reconcileTabOrder falls back to terminals-first when the stored order is
|
||||
// unset, jumping the new tab to index 0.
|
||||
const fresh = useAppStore.getState()
|
||||
const termIds = (fresh.tabsByWorktree[worktreeId] ?? []).map((t) => t.id)
|
||||
const editorIds = fresh.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id)
|
||||
const browserIds = (fresh.browserTabsByWorktree?.[worktreeId] ?? []).map((t) => t.id)
|
||||
const base = reconcileTabOrder(
|
||||
fresh.tabBarOrderByWorktree[worktreeId],
|
||||
termIds,
|
||||
editorIds,
|
||||
browserIds
|
||||
)
|
||||
const order = base.filter((id) => id !== tab.id)
|
||||
order.push(tab.id)
|
||||
fresh.setTabBarOrder(worktreeId, order)
|
||||
|
||||
fresh.setRecentQuickCommandForGroup(groupId, command.id)
|
||||
|
||||
return { tabId: tab.id }
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines */
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
|
@ -78,6 +79,7 @@ function runtimeScopedStateReset(): Partial<AppState> {
|
|||
deferredSshReconnectTargets: [],
|
||||
deferredSshSessionIdsByTabId: {},
|
||||
cacheTimerByKey: {},
|
||||
recentQuickCommandIdByGroup: {},
|
||||
expandedDirs: {},
|
||||
pendingExplorerReveal: null,
|
||||
openFiles: [],
|
||||
|
|
|
|||
|
|
@ -905,10 +905,14 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
|||
groupId,
|
||||
remainingGroups[0]?.id ?? null
|
||||
)
|
||||
// Why: drop the dead group's recent-quick-command entry so the in-memory
|
||||
// map can't grow unbounded as users open/close groups.
|
||||
const { [groupId]: _droppedRecent, ...remainingRecent } = current.recentQuickCommandIdByGroup
|
||||
return {
|
||||
groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: remainingGroups },
|
||||
layoutByWorktree: collapsedState.layoutByWorktree,
|
||||
activeGroupIdByWorktree: collapsedState.activeGroupIdByWorktree,
|
||||
recentQuickCommandIdByGroup: remainingRecent,
|
||||
...(current.activeWorktreeId === worktreeId
|
||||
? buildActiveSurfacePatch(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -183,6 +183,11 @@ export type TerminalSlice = {
|
|||
expandedPaneByTabId: Record<string, boolean>
|
||||
canExpandPaneByTabId: Record<string, boolean>
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
|
||||
/** Most recently run quick-command id per tab group. In-memory only; resets
|
||||
* on app restart so a stale id from a deleted command can't surface as the
|
||||
* split-button label across sessions. */
|
||||
recentQuickCommandIdByGroup: Record<string, string>
|
||||
setRecentQuickCommandForGroup: (groupId: string, quickCommandId: string) => void
|
||||
pendingStartupByTabId: Record<
|
||||
string,
|
||||
{
|
||||
|
|
@ -376,6 +381,16 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
deferredSshReconnectTargets: [],
|
||||
deferredSshSessionIdsByTabId: {},
|
||||
cacheTimerByKey: {},
|
||||
recentQuickCommandIdByGroup: {},
|
||||
|
||||
setRecentQuickCommandForGroup: (groupId, quickCommandId) => {
|
||||
set((s) => ({
|
||||
recentQuickCommandIdByGroup: {
|
||||
...s.recentQuickCommandIdByGroup,
|
||||
[groupId]: quickCommandId
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
setCacheTimerStartedAt: (key, ts) => {
|
||||
set((s) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue