mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #72987 from NousResearch/bb/context-menu-parity
Mirror every desktop kebab menu to right-click
This commit is contained in:
commit
67e592b015
12 changed files with 925 additions and 565 deletions
|
|
@ -3,17 +3,10 @@ import { memo, useEffect, useRef, useState } from 'react'
|
|||
|
||||
import { WorktreeDialog } from '@/app/chat/sidebar/projects/worktree-dialog'
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { type ActionItemSpec, ActionsContextMenu, ActionsMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DiffCount } from '@/components/ui/diff-count'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { HermesGitBranch } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $repoStatus, $repoWorktrees } from '@/store/coding-status'
|
||||
|
|
@ -153,34 +146,87 @@ export const CodingStatusRow = memo(function CodingStatusRow({
|
|||
// they're the only change (otherwise +/- tells the story).
|
||||
const untrackedOnly = !hasLineDelta && status.untracked > 0
|
||||
|
||||
// The branch actions, rendered identically by the kebab dropdown and the
|
||||
// row's right-click menu so the two never drift. `onBranchOff` gates the
|
||||
// whole menu (omitted = remote backend), matching the kebab.
|
||||
const renderBranchItems = (kit: MenuKit) => {
|
||||
const branchItems: ActionItemSpec[] = branchTargets.map(target => ({
|
||||
key: target.base ?? '__head__',
|
||||
label: <span className="truncate">{target.label}</span>,
|
||||
onSelect: () => startBranch(target.base)
|
||||
}))
|
||||
|
||||
const worktreeItems: ActionItemSpec[] = otherWorktrees.map(worktree => ({
|
||||
key: worktree.path,
|
||||
label: <span className="truncate">{worktree.branch}</span>,
|
||||
onSelect: () => onOpenWorktree?.(worktree.path)
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<kit.Label className={MENU_SECTION}>{s.newBranch}</kit.Label>
|
||||
{branchItems.map(item => renderActionItem(kit, item))}
|
||||
{switchTarget &&
|
||||
renderActionItem(kit, {
|
||||
key: '__switch__',
|
||||
label: <span className="truncate">{s.switchTo(switchTarget)}</span>,
|
||||
onSelect: () => void switchToBranch(switchTarget)
|
||||
})}
|
||||
<kit.Separator />
|
||||
<kit.Label className={MENU_SECTION}>{s.worktrees}</kit.Label>
|
||||
{worktreeItems.map(item => renderActionItem(kit, item))}
|
||||
{/* Create a fresh worktree off the current HEAD (the generic "spin up a
|
||||
worktree here", mirroring the sidebar's + button). */}
|
||||
{renderActionItem(kit, {
|
||||
key: '__start__',
|
||||
label: <span className="truncate">{p.startWork}</span>,
|
||||
onSelect: () => startBranch(undefined)
|
||||
})}
|
||||
{onConvertBranch &&
|
||||
renderActionItem(kit, {
|
||||
key: '__convert__',
|
||||
label: <span className="truncate">{p.convertBranch}</span>,
|
||||
onSelect: () => startBranch(undefined)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusRow
|
||||
// The base "where am I working" strip is part of the composer surface
|
||||
// itself, so it inherits the composer's width and clipped top radius.
|
||||
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
|
||||
// Static branch glyph — never the loading spinner. This row only renders
|
||||
// once `status` exists, so a spinner here only ever fired on *refreshes*
|
||||
// of an already-loaded repo (window focus, turn settle), reading as an
|
||||
// annoying icon "blip" with no first-load value. Refreshes are silent.
|
||||
leading={<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />}
|
||||
onActivate={onOpen}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span
|
||||
className="min-w-0 truncate text-xs font-normal text-muted-foreground/92 transition-colors group-hover/status-row:text-foreground/90"
|
||||
title={branchLabel}
|
||||
>
|
||||
{branchLabel}
|
||||
</span>
|
||||
<ActionsContextMenu contentClassName="w-60" disabled={!onBranchOff} items={renderBranchItems}>
|
||||
<StatusRow
|
||||
// The base "where am I working" strip is part of the composer surface
|
||||
// itself, so it inherits the composer's width and clipped top radius.
|
||||
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
|
||||
// Static branch glyph — never the loading spinner. This row only renders
|
||||
// once `status` exists, so a spinner here only ever fired on *refreshes*
|
||||
// of an already-loaded repo (window focus, turn settle), reading as an
|
||||
// annoying icon "blip" with no first-load value. Refreshes are silent.
|
||||
leading={<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />}
|
||||
onActivate={onOpen}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span
|
||||
className="min-w-0 truncate text-xs font-normal text-muted-foreground/92 transition-colors group-hover/status-row:text-foreground/90"
|
||||
title={branchLabel}
|
||||
>
|
||||
{branchLabel}
|
||||
</span>
|
||||
|
||||
{/* Branch actions kebab — same pattern as the session/worktree rows.
|
||||
ALWAYS laid out; only its opacity flips on hover/focus/open, so
|
||||
revealing it never reflows the row (no layout shift). pointer-events
|
||||
follow opacity so the invisible trigger isn't clickable at rest. */}
|
||||
{onBranchOff && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* Branch actions kebab — same pattern as the session/worktree rows.
|
||||
ALWAYS laid out; only its opacity flips on hover/focus/open, so
|
||||
revealing it never reflows the row (no layout shift). pointer-events
|
||||
follow opacity so the invisible trigger isn't clickable at rest. */}
|
||||
{onBranchOff && (
|
||||
<ActionsMenu
|
||||
align="end"
|
||||
contentClassName="w-60"
|
||||
// The row sits at the bottom of the screen (above the composer),
|
||||
// so the menu opens upward.
|
||||
items={renderBranchItems}
|
||||
side="top"
|
||||
>
|
||||
<Button
|
||||
aria-label={s.newBranch}
|
||||
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
|
||||
|
|
@ -197,78 +243,42 @@ export const CodingStatusRow = memo(function CodingStatusRow({
|
|||
>
|
||||
<Codicon name="kebab-vertical" size="0.8rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
{/* The row sits at the bottom of the screen (above the composer),
|
||||
so the menu opens upward. */}
|
||||
<DropdownMenuContent align="end" className="w-60" side="top" sideOffset={6}>
|
||||
<DropdownMenuLabel className={MENU_SECTION}>{s.newBranch}</DropdownMenuLabel>
|
||||
{branchTargets.map(target => (
|
||||
<DropdownMenuItem key={target.base ?? '__head__'} onSelect={() => startBranch(target.base)}>
|
||||
<span className="truncate">{target.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</ActionsMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{switchTarget && (
|
||||
<DropdownMenuItem onSelect={() => void switchToBranch(switchTarget)}>
|
||||
<span className="truncate">{s.switchTo(switchTarget)}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className={MENU_SECTION}>{s.worktrees}</DropdownMenuLabel>
|
||||
{otherWorktrees.map(worktree => (
|
||||
<DropdownMenuItem key={worktree.path} onSelect={() => onOpenWorktree?.(worktree.path)}>
|
||||
<span className="truncate">{worktree.branch}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{/* Create a fresh worktree off the current HEAD (the generic
|
||||
"spin up a worktree here", mirroring the sidebar's + button). */}
|
||||
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
|
||||
<span className="truncate">{p.startWork}</span>
|
||||
</DropdownMenuItem>
|
||||
{/* Create a fresh worktree off the current HEAD (the generic
|
||||
"spin up a worktree here", mirroring the sidebar's + button). */}
|
||||
{onConvertBranch && (
|
||||
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
|
||||
<span className="truncate">{p.convertBranch}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{(status.ahead > 0 || status.behind > 0) && (
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
|
||||
{status.ahead > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
|
||||
<span aria-hidden>↑</span>
|
||||
{status.ahead}
|
||||
</span>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
|
||||
<span aria-hidden>↓</span>
|
||||
{status.behind}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(status.ahead > 0 || status.behind > 0) && (
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
|
||||
{status.ahead > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
|
||||
<span aria-hidden>↑</span>
|
||||
{status.ahead}
|
||||
</span>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
|
||||
<span aria-hidden>↓</span>
|
||||
{status.behind}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasLineDelta ? (
|
||||
<DiffCount
|
||||
added={status.added}
|
||||
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
removed={status.removed}
|
||||
/>
|
||||
) : untrackedOnly ? (
|
||||
<span
|
||||
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
>
|
||||
{s.changed(status.untracked)}
|
||||
</span>
|
||||
) : null}
|
||||
</StatusRow>
|
||||
{hasLineDelta ? (
|
||||
<DiffCount
|
||||
added={status.added}
|
||||
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
removed={status.removed}
|
||||
/>
|
||||
) : untrackedOnly ? (
|
||||
<span
|
||||
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
>
|
||||
{s.changed(status.untracked)}
|
||||
</span>
|
||||
) : null}
|
||||
</StatusRow>
|
||||
</ActionsContextMenu>
|
||||
|
||||
{resolvedRepoPath && onOpenWorktree && (
|
||||
<WorktreeDialog
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
|
|
@ -31,8 +32,10 @@ vi.mock('./model', () => ({
|
|||
|
||||
// ProjectMenu (the kebab) has its own dedicated test file — stub it here so
|
||||
// this file only exercises overview-row's own Tip usage (the disclosure
|
||||
// toggle) plus the WorkspaceAddButton wiring.
|
||||
// toggle) plus the WorkspaceAddButton wiring. ProjectContextMenu (the row's
|
||||
// right-click wrapper) is stubbed as a pass-through so the row still renders.
|
||||
vi.mock('./project-menu', () => ({
|
||||
ProjectContextMenu: ({ children }: { children: ReactNode }) => children,
|
||||
ProjectMenu: () => null
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
} from '../chrome'
|
||||
|
||||
import { latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model'
|
||||
import { ProjectMenu } from './project-menu'
|
||||
import { ProjectContextMenu, ProjectMenu } from './project-menu'
|
||||
import type { SidebarProjectTree } from './workspace-groups'
|
||||
import { WorkspaceAddButton } from './workspace-header'
|
||||
|
||||
|
|
@ -112,50 +112,61 @@ export function ProjectOverviewRow({
|
|||
<SidebarRowLead>{projectIcon(project)}</SidebarRowLead>
|
||||
)
|
||||
|
||||
const shell = (
|
||||
<SidebarRowShell
|
||||
actions={
|
||||
<>
|
||||
{/* Home has no folder to start a chat in — the sidebar's own "New
|
||||
session" is that button — and no record to rename or delete. */}
|
||||
{onNewSession && !project.isNoProject && (
|
||||
<WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />
|
||||
)}
|
||||
{!project.isNoProject && <ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />}
|
||||
</>
|
||||
}
|
||||
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
|
||||
ref={rowRef}
|
||||
>
|
||||
<SidebarRowCluster className="min-w-0 flex-1">
|
||||
{lead}
|
||||
<SidebarRowLink
|
||||
aria-label={s.projects.enter(project.label)}
|
||||
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
|
||||
onClick={() => onEnter?.(project.id)}
|
||||
>
|
||||
{project.label}
|
||||
</SidebarRowLink>
|
||||
{preview.length > 0 ? (
|
||||
<Tip label={s.projects.toggle(project.label)}>
|
||||
<button
|
||||
aria-label={s.projects.toggle(project.label)}
|
||||
className="flex flex-1 items-center self-stretch bg-transparent p-0"
|
||||
onClick={toggleOpen}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret
|
||||
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
</Tip>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
</SidebarRowCluster>
|
||||
</SidebarRowShell>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn(dragging && 'relative z-10')} ref={ref} style={style}>
|
||||
<SidebarRowShell
|
||||
actions={
|
||||
<>
|
||||
{/* Home has no folder to start a chat in — the sidebar's own "New
|
||||
session" is that button — and no record to rename or delete. */}
|
||||
{onNewSession && !project.isNoProject && (
|
||||
<WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />
|
||||
)}
|
||||
{!project.isNoProject && <ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />}
|
||||
</>
|
||||
}
|
||||
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
|
||||
ref={rowRef}
|
||||
>
|
||||
<SidebarRowCluster className="min-w-0 flex-1">
|
||||
{lead}
|
||||
<SidebarRowLink
|
||||
aria-label={s.projects.enter(project.label)}
|
||||
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
|
||||
onClick={() => onEnter?.(project.id)}
|
||||
>
|
||||
{project.label}
|
||||
</SidebarRowLink>
|
||||
{preview.length > 0 ? (
|
||||
<Tip label={s.projects.toggle(project.label)}>
|
||||
<button
|
||||
aria-label={s.projects.toggle(project.label)}
|
||||
className="flex flex-1 items-center self-stretch bg-transparent p-0"
|
||||
onClick={toggleOpen}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret
|
||||
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
</Tip>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
</SidebarRowCluster>
|
||||
</SidebarRowShell>
|
||||
{/* Home has no per-project actions, so it gets no right-click menu. */}
|
||||
{project.isNoProject ? (
|
||||
shell
|
||||
) : (
|
||||
<ProjectContextMenu isActive={isActive} project={project}>
|
||||
{shell}
|
||||
</ProjectContextMenu>
|
||||
)}
|
||||
{open && preview.length > 0 && <SidebarRowNest>{renderRows?.(preview)}</SidebarRowNest>}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ColorSwatches } from '@/components/ui/color-swatches'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { PROFILE_SWATCHES } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Curated codicons for a project glyph (tinted by the chosen color). Shared by
|
||||
// the kebab's Appearance popover and the right-click menu's Appearance submenu
|
||||
// so both offer the same picker.
|
||||
export const PROJECT_ICONS = [
|
||||
'folder-library',
|
||||
'repo',
|
||||
'rocket',
|
||||
'beaker',
|
||||
'flame',
|
||||
'star-full',
|
||||
'heart',
|
||||
'zap',
|
||||
'target',
|
||||
'lightbulb',
|
||||
'tools',
|
||||
'device-desktop',
|
||||
'device-mobile',
|
||||
'terminal',
|
||||
'dashboard',
|
||||
'globe',
|
||||
'broadcast',
|
||||
'cloud',
|
||||
'database',
|
||||
'package',
|
||||
'book',
|
||||
'organization',
|
||||
'bug',
|
||||
'shield',
|
||||
'key',
|
||||
'gift',
|
||||
'telescope',
|
||||
'home'
|
||||
]
|
||||
|
||||
interface ProjectAppearancePickerProps {
|
||||
color: null | string
|
||||
icon: null | string
|
||||
noColorLabel: string
|
||||
onColor: (color: null | string) => void
|
||||
onIcon: (icon: null | string) => void
|
||||
}
|
||||
|
||||
/** Color swatches + icon grid for a project's appearance — one component so the
|
||||
* kebab popover and the right-click submenu render an identical picker. */
|
||||
export function ProjectAppearancePicker({ color, icon, noColorLabel, onColor, onIcon }: ProjectAppearancePickerProps) {
|
||||
return (
|
||||
<>
|
||||
<ColorSwatches
|
||||
clearIcon="circle-slash"
|
||||
clearLabel={noColorLabel}
|
||||
onChange={onColor}
|
||||
swatches={PROFILE_SWATCHES}
|
||||
value={color ?? null}
|
||||
/>
|
||||
{/* Same 6 columns + gap as the swatch grid so the picker keeps the
|
||||
profile picker's width (icons flex to fill, not fixed-width). */}
|
||||
<div className="mt-2 grid grid-cols-6 gap-1.5">
|
||||
{PROJECT_ICONS.map(name => (
|
||||
<Tip key={name} label={name}>
|
||||
<button
|
||||
aria-label={name}
|
||||
className={cn(
|
||||
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
|
||||
icon === name && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
onClick={() => onIcon(icon === name ? null : name)}
|
||||
style={icon === name && color ? { color } : undefined}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={name} size="0.8125rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@ import { useStore } from '@nanostores/react'
|
|||
import type * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { type ActionItemSpec, ActionsContextMenu, DROPDOWN_KIT, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ColorSwatches } from '@/components/ui/color-swatches'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -15,7 +15,6 @@ import {
|
|||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PROFILE_SWATCHES } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
|
||||
import {
|
||||
|
|
@ -28,45 +27,108 @@ import {
|
|||
setProjectAppearance
|
||||
} from '@/store/projects'
|
||||
|
||||
import { ProjectAppearancePicker } from './project-appearance'
|
||||
import type { SidebarProjectTree } from './workspace-groups'
|
||||
|
||||
// Curated codicons for the project glyph (tinted by the chosen color).
|
||||
const ICONS = [
|
||||
'folder-library',
|
||||
'repo',
|
||||
'rocket',
|
||||
'beaker',
|
||||
'flame',
|
||||
'star-full',
|
||||
'heart',
|
||||
'zap',
|
||||
'target',
|
||||
'lightbulb',
|
||||
'tools',
|
||||
'device-desktop',
|
||||
'device-mobile',
|
||||
'terminal',
|
||||
'dashboard',
|
||||
'globe',
|
||||
'broadcast',
|
||||
'cloud',
|
||||
'database',
|
||||
'package',
|
||||
'book',
|
||||
'organization',
|
||||
'bug',
|
||||
'shield',
|
||||
'key',
|
||||
'gift',
|
||||
'telescope',
|
||||
'home'
|
||||
]
|
||||
// Shared per-project state + handlers, so the kebab dropdown and the row's
|
||||
// right-click menu drive the exact same actions. Modeled on git GUIs (GitHub
|
||||
// Desktop / GitKraken): reveal in the file manager, copy path, and "Remove from
|
||||
// sidebar" (never deletes files — auto projects are dismissed, explicit ones
|
||||
// drop their entry). Explicit projects additionally get rename / add folder /
|
||||
// set active.
|
||||
function useProjectActions({
|
||||
project,
|
||||
isActive,
|
||||
scoped,
|
||||
onExitScope
|
||||
}: {
|
||||
project: SidebarProjectTree
|
||||
isActive: boolean
|
||||
scoped: boolean
|
||||
onExitScope?: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
const target = { id: project.id, name: project.label }
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
||||
|
||||
// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal
|
||||
// in the file manager, copy path, and "Remove from sidebar" (never deletes files
|
||||
// — auto projects are dismissed, explicit ones drop their entry). Explicit
|
||||
// projects additionally get rename / add folder / set active. Hidden until the
|
||||
// row is hovered (group/workspace), matching the + affordance.
|
||||
const removeAuto = () => {
|
||||
dismissAutoProject(project.id)
|
||||
|
||||
if (scoped) {
|
||||
onExitScope?.()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
await deleteProject(project.id)
|
||||
|
||||
if (scoped) {
|
||||
onExitScope?.()
|
||||
}
|
||||
}
|
||||
|
||||
// Rename / add folder / set active — explicit projects only (auto ones lack a
|
||||
// materialized record). Appearance is handled per-surface (popover vs submenu)
|
||||
// by the caller since its picker chrome differs.
|
||||
const identityItems: ActionItemSpec[] = project.isAuto
|
||||
? []
|
||||
: [
|
||||
{ icon: 'edit', key: 'rename', label: p.menuRename, onSelect: () => openProjectRename(target) },
|
||||
{
|
||||
icon: 'new-folder',
|
||||
key: 'add-folder',
|
||||
label: p.menuAddFolder,
|
||||
onSelect: () => openProjectAddFolder(target)
|
||||
},
|
||||
{
|
||||
disabled: isActive,
|
||||
icon: 'target',
|
||||
key: 'set-active',
|
||||
label: p.menuSetActive,
|
||||
onSelect: () => void setActiveProject(project.id)
|
||||
}
|
||||
]
|
||||
|
||||
const pathItems: ActionItemSpec[] = [
|
||||
{
|
||||
disabled: !project.path,
|
||||
icon: 'folder-opened',
|
||||
key: 'reveal',
|
||||
label: p.reveal,
|
||||
onSelect: () => void revealPath(project.path)
|
||||
},
|
||||
{ disabled: !project.path, icon: 'copy', key: 'copy', label: p.copyPath, onSelect: () => void copyPath(project.path) }
|
||||
]
|
||||
|
||||
const dangerItem: ActionItemSpec = project.isAuto
|
||||
? { icon: 'trash', key: 'remove', label: p.removeFromSidebar, onSelect: removeAuto, variant: 'destructive' }
|
||||
: {
|
||||
icon: 'trash',
|
||||
key: 'delete',
|
||||
label: `${p.menuDelete}…`,
|
||||
onSelect: () => setConfirmDeleteOpen(true),
|
||||
variant: 'destructive'
|
||||
}
|
||||
|
||||
const confirmDialog = (
|
||||
<ConfirmDialog
|
||||
confirmLabel={p.menuDelete}
|
||||
description={p.deleteConfirm}
|
||||
destructive
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={confirmDelete}
|
||||
open={confirmDeleteOpen}
|
||||
title={`${p.menuDelete} "${project.label}"?`}
|
||||
/>
|
||||
)
|
||||
|
||||
return { confirmDialog, dangerItem, identityItems, pathItems }
|
||||
}
|
||||
|
||||
// Per-project actions. The kebab keeps its row-anchored Appearance popover; the
|
||||
// right-click menu (ProjectContextMenu) renders the same actions with Appearance
|
||||
// as a submenu. Hidden until the row is hovered, matching the + affordance.
|
||||
export function ProjectMenu({
|
||||
project,
|
||||
isActive,
|
||||
|
|
@ -88,28 +150,17 @@ export function ProjectMenu({
|
|||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
const target = { id: project.id, name: project.label }
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
||||
const [appearanceOpen, setAppearanceOpen] = useState(false)
|
||||
// Open toward the content area: right when the sidebar is on the left, left
|
||||
// when the panes are flipped (sidebar on the right).
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
|
||||
const removeAuto = () => {
|
||||
dismissAutoProject(project.id)
|
||||
|
||||
if (scoped) {
|
||||
onExitScope?.()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
await deleteProject(project.id)
|
||||
|
||||
if (scoped) {
|
||||
onExitScope?.()
|
||||
}
|
||||
}
|
||||
const { confirmDialog, dangerItem, identityItems, pathItems } = useProjectActions({
|
||||
isActive,
|
||||
onExitScope,
|
||||
project,
|
||||
scoped
|
||||
})
|
||||
|
||||
// Appearance writes route through the adopt-aware helper: an auto project is
|
||||
// materialized on its first change (its id then changes), so close the picker
|
||||
|
|
@ -193,42 +244,15 @@ export function ProjectMenu({
|
|||
)
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => openProjectRename(target)}>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{p.menuRename}</span>
|
||||
</DropdownMenuItem>
|
||||
{identityItems.slice(0, 1).map(item => renderActionItem(DROPDOWN_KIT, item))}
|
||||
{appearanceItem}
|
||||
<DropdownMenuItem onSelect={() => openProjectAddFolder(target)}>
|
||||
<Codicon name="new-folder" size="0.875rem" />
|
||||
<span>{p.menuAddFolder}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={isActive} onSelect={() => void setActiveProject(project.id)}>
|
||||
<Codicon name="target" size="0.875rem" />
|
||||
<span>{p.menuSetActive}</span>
|
||||
</DropdownMenuItem>
|
||||
{identityItems.slice(1).map(item => renderActionItem(DROPDOWN_KIT, item))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem disabled={!project.path} onSelect={() => void revealPath(project.path)}>
|
||||
<Codicon name="folder-opened" size="0.875rem" />
|
||||
<span>{p.reveal}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!project.path} onSelect={() => void copyPath(project.path)}>
|
||||
<Codicon name="copy" size="0.875rem" />
|
||||
<span>{p.copyPath}</span>
|
||||
</DropdownMenuItem>
|
||||
{pathItems.map(item => renderActionItem(DROPDOWN_KIT, item))}
|
||||
<DropdownMenuSeparator />
|
||||
{project.isAuto ? (
|
||||
<DropdownMenuItem onSelect={removeAuto} variant="destructive">
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>{p.removeFromSidebar}</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => setConfirmDeleteOpen(true)} variant="destructive">
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>{`${p.menuDelete}…`}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{renderActionItem(DROPDOWN_KIT, dangerItem)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<PopoverContent
|
||||
|
|
@ -238,43 +262,80 @@ export function ProjectMenu({
|
|||
side={panesFlipped ? 'left' : 'right'}
|
||||
sideOffset={6}
|
||||
>
|
||||
<ColorSwatches
|
||||
clearIcon="circle-slash"
|
||||
clearLabel={p.noColor}
|
||||
onChange={color => void applyAppearance({ color })}
|
||||
swatches={PROFILE_SWATCHES}
|
||||
value={project.color ?? null}
|
||||
<ProjectAppearancePicker
|
||||
color={project.color ?? null}
|
||||
icon={project.icon ?? null}
|
||||
noColorLabel={p.noColor}
|
||||
onColor={color => void applyAppearance({ color })}
|
||||
onIcon={icon => void applyAppearance({ icon })}
|
||||
/>
|
||||
{/* Same 6 columns + gap as the swatch grid so the popover keeps the
|
||||
profile picker's width (icons flex to fill, not fixed-width). */}
|
||||
<div className="mt-2 grid grid-cols-6 gap-1.5">
|
||||
{ICONS.map(name => (
|
||||
<Tip key={name} label={name}>
|
||||
<button
|
||||
aria-label={name}
|
||||
className={cn(
|
||||
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
|
||||
project.icon === name && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
onClick={() => void applyAppearance({ icon: project.icon === name ? null : name })}
|
||||
style={project.icon === name && project.color ? { color: project.color } : undefined}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={name} size="0.8125rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
<ConfirmDialog
|
||||
confirmLabel={p.menuDelete}
|
||||
description={p.deleteConfirm}
|
||||
destructive
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={confirmDelete}
|
||||
open={confirmDeleteOpen}
|
||||
title={`${p.menuDelete} "${project.label}"?`}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProjectContextMenuProps {
|
||||
project: SidebarProjectTree
|
||||
isActive: boolean
|
||||
scoped?: boolean
|
||||
onExitScope?: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// Wrap a project row so right-clicking it opens the same actions as its kebab.
|
||||
// The kebab's row-anchored Appearance popover can't nest in a context menu, so
|
||||
// here Appearance is a submenu with the same swatch + icon picker.
|
||||
export function ProjectContextMenu({ project, isActive, scoped = false, onExitScope, children }: ProjectContextMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
|
||||
const { confirmDialog, dangerItem, identityItems, pathItems } = useProjectActions({
|
||||
isActive,
|
||||
onExitScope,
|
||||
project,
|
||||
scoped
|
||||
})
|
||||
|
||||
const canTheme = !project.isAuto || Boolean(project.path)
|
||||
|
||||
const applyAppearance = (patch: { color?: null | string; icon?: null | string }) => {
|
||||
void setProjectAppearance(project, patch)
|
||||
}
|
||||
|
||||
const items = (kit: MenuKit) => (
|
||||
<>
|
||||
{identityItems.map(item => renderActionItem(kit, item))}
|
||||
{canTheme && (
|
||||
<kit.Sub>
|
||||
<kit.SubTrigger>
|
||||
<Codicon name="symbol-color" size="0.875rem" />
|
||||
<span>{p.menuAppearance}</span>
|
||||
</kit.SubTrigger>
|
||||
<kit.SubContent className="w-auto p-2">
|
||||
<ProjectAppearancePicker
|
||||
color={project.color ?? null}
|
||||
icon={project.icon ?? null}
|
||||
noColorLabel={p.noColor}
|
||||
onColor={color => applyAppearance({ color })}
|
||||
onIcon={icon => applyAppearance({ icon })}
|
||||
/>
|
||||
</kit.SubContent>
|
||||
</kit.Sub>
|
||||
)}
|
||||
{(identityItems.length > 0 || canTheme) && <kit.Separator />}
|
||||
{pathItems.map(item => renderActionItem(kit, item))}
|
||||
<kit.Separator />
|
||||
{renderActionItem(kit, dangerItem)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionsContextMenu ariaLabel={p.menu} contentClassName="w-48" items={items}>
|
||||
{children}
|
||||
</ActionsContextMenu>
|
||||
{confirmDialog}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@ import { SidebarLoadMoreRow } from '../load-more-row'
|
|||
|
||||
import { SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
|
||||
import type { SidebarSessionGroup } from './workspace-groups'
|
||||
import { WorkspaceAddButton, WorkspaceHeader, WorkspaceMenu, WorkspaceShowMoreButton } from './workspace-header'
|
||||
import {
|
||||
WorkspaceAddButton,
|
||||
WorkspaceContextMenu,
|
||||
WorkspaceHeader,
|
||||
WorkspaceMenu,
|
||||
WorkspaceShowMoreButton
|
||||
} from './workspace-header'
|
||||
|
||||
interface SidebarWorkspaceGroupProps {
|
||||
group: SidebarSessionGroup
|
||||
|
|
@ -104,29 +110,31 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
|
|||
|
||||
return (
|
||||
<SidebarRowStack>
|
||||
<WorkspaceHeader
|
||||
action={
|
||||
(onNewSession || isProfileGroup || onRemove) && (
|
||||
<div className="flex items-center">
|
||||
{(onNewSession || isProfileGroup) && (
|
||||
<WorkspaceAddButton
|
||||
label={s.newSessionIn(group.label)}
|
||||
// Profile groups start a fresh session in that profile but keep
|
||||
// the all-profiles browse view; workspace groups seed the new
|
||||
// session's cwd. Main checkout lanes are branch-targeted.
|
||||
onClick={() => void handleNewSession()}
|
||||
/>
|
||||
)}
|
||||
{onRemove && <WorkspaceMenu onRemove={onRemove} path={group.path} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
icon={leadingIcon}
|
||||
label={group.label}
|
||||
onToggle={toggleOpen}
|
||||
open={open}
|
||||
title={group.path ?? undefined}
|
||||
/>
|
||||
<WorkspaceContextMenu onRemove={onRemove} path={group.path}>
|
||||
<WorkspaceHeader
|
||||
action={
|
||||
(onNewSession || isProfileGroup || onRemove) && (
|
||||
<div className="flex items-center">
|
||||
{(onNewSession || isProfileGroup) && (
|
||||
<WorkspaceAddButton
|
||||
label={s.newSessionIn(group.label)}
|
||||
// Profile groups start a fresh session in that profile but keep
|
||||
// the all-profiles browse view; workspace groups seed the new
|
||||
// session's cwd. Main checkout lanes are branch-targeted.
|
||||
onClick={() => void handleNewSession()}
|
||||
/>
|
||||
)}
|
||||
{onRemove && <WorkspaceMenu onRemove={onRemove} path={group.path} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
icon={leadingIcon}
|
||||
label={group.label}
|
||||
onToggle={toggleOpen}
|
||||
open={open}
|
||||
title={group.path ?? undefined}
|
||||
/>
|
||||
</WorkspaceContextMenu>
|
||||
{open && (
|
||||
<>
|
||||
{visibleSessions.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
import type * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ActionsContextMenu, ActionsMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -83,40 +77,77 @@ export function WorkspaceShowMoreButton({
|
|||
// Per-worktree actions (linked worktree lanes only), mirroring the session row
|
||||
// and ProjectMenu kebab: reveal in the file manager, copy path, and remove the
|
||||
// worktree (runs a real `git worktree remove` via the caller's confirm dialog).
|
||||
export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) {
|
||||
// Shared by the kebab dropdown and the header's right-click menu so both match.
|
||||
function useWorkspaceItems({ path, onRemove }: { path: null | string; onRemove: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
|
||||
return (kit: MenuKit) => (
|
||||
<>
|
||||
{renderActionItem(kit, {
|
||||
disabled: !path,
|
||||
icon: 'folder-opened',
|
||||
key: 'reveal',
|
||||
label: p.reveal,
|
||||
onSelect: () => void revealPath(path)
|
||||
})}
|
||||
{renderActionItem(kit, {
|
||||
disabled: !path,
|
||||
icon: 'copy',
|
||||
key: 'copy',
|
||||
label: p.copyPath,
|
||||
onSelect: () => void copyPath(path)
|
||||
})}
|
||||
<kit.Separator />
|
||||
{renderActionItem(kit, {
|
||||
icon: 'trash',
|
||||
key: 'remove',
|
||||
label: `${p.removeWorktree}…`,
|
||||
onSelect: onRemove,
|
||||
variant: 'destructive'
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
const items = useWorkspaceItems({ onRemove, path })
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tip label={p.menu}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={p.menu}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100 data-[state=open]:opacity-100"
|
||||
onClick={event => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.75rem" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-48" sideOffset={6}>
|
||||
<DropdownMenuItem disabled={!path} onSelect={() => void revealPath(path)}>
|
||||
<Codicon name="folder-opened" size="0.875rem" />
|
||||
<span>{p.reveal}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!path} onSelect={() => void copyPath(path)}>
|
||||
<Codicon name="copy" size="0.875rem" />
|
||||
<span>{p.copyPath}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onRemove} variant="destructive">
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>{`${p.removeWorktree}…`}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ActionsMenu ariaLabel={p.menu} contentClassName="w-48" items={items} tooltip={p.menu}>
|
||||
<button
|
||||
aria-label={p.menu}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100 data-[state=open]:opacity-100"
|
||||
onClick={event => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.75rem" />
|
||||
</button>
|
||||
</ActionsMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Wrap a worktree lane's header so right-clicking it opens the same actions as
|
||||
// its kebab. `disabled` renders children bare (a lane with no removable path).
|
||||
export function WorkspaceContextMenu({
|
||||
path,
|
||||
onRemove,
|
||||
children
|
||||
}: {
|
||||
path: null | string
|
||||
onRemove?: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
const items = useWorkspaceItems({ onRemove: onRemove ?? (() => {}), path })
|
||||
|
||||
return (
|
||||
<ActionsContextMenu ariaLabel={p.menu} contentClassName="w-48" disabled={!onRemove} items={items}>
|
||||
{children}
|
||||
</ActionsContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +187,9 @@ export function WorkspaceHeader({
|
|||
label,
|
||||
onToggle,
|
||||
open,
|
||||
title
|
||||
title,
|
||||
ref,
|
||||
...rest
|
||||
}: {
|
||||
action?: React.ReactNode
|
||||
emphasis?: boolean
|
||||
|
|
@ -166,13 +199,15 @@ export function WorkspaceHeader({
|
|||
open: boolean
|
||||
/** Hover tooltip — the lane's full on-disk path (worktree / repo root). */
|
||||
title?: string
|
||||
}) {
|
||||
} & React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem]',
|
||||
emphasis ? 'font-semibold text-(--ui-text-secondary)' : 'font-medium text-(--ui-text-tertiary)'
|
||||
)}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -8,19 +8,10 @@ import {
|
|||
closeTreeTabsToRight,
|
||||
treeTabCloseTargets
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import { type ActionItemSpec, ActionsContextMenu, ActionsMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ColorSwatches } from '@/components/ui/color-swatches'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -30,18 +21,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
|
@ -131,42 +111,6 @@ interface SessionActions {
|
|||
onHideTabBar?: () => void
|
||||
}
|
||||
|
||||
type MenuItem = typeof DropdownMenuItem | typeof ContextMenuItem
|
||||
|
||||
/** A menu flavour (dropdown / context) — item + separator + submenu components. */
|
||||
interface MenuKit {
|
||||
Item: MenuItem
|
||||
Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator
|
||||
Sub: typeof DropdownMenuSub | typeof ContextMenuSub
|
||||
SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger
|
||||
SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent
|
||||
}
|
||||
|
||||
const DROPDOWN_KIT: MenuKit = {
|
||||
Item: DropdownMenuItem,
|
||||
Separator: DropdownMenuSeparator,
|
||||
Sub: DropdownMenuSub,
|
||||
SubContent: DropdownMenuSubContent,
|
||||
SubTrigger: DropdownMenuSubTrigger
|
||||
}
|
||||
|
||||
const CONTEXT_KIT: MenuKit = {
|
||||
Item: ContextMenuItem,
|
||||
Separator: ContextMenuSeparator,
|
||||
Sub: ContextMenuSub,
|
||||
SubContent: ContextMenuSubContent,
|
||||
SubTrigger: ContextMenuSubTrigger
|
||||
}
|
||||
|
||||
interface ItemSpec {
|
||||
className?: string
|
||||
disabled: boolean
|
||||
icon: string
|
||||
label: string
|
||||
onSelect: (event: Event) => void
|
||||
variant?: 'destructive'
|
||||
}
|
||||
|
||||
// The color picker inside the session menu's Appearance submenu. Its own
|
||||
// component so only an OPEN submenu subscribes to the stores (not every row's
|
||||
// menu). Reads/writes the override keyed by the DURABLE id so a color survives
|
||||
|
|
@ -212,11 +156,11 @@ function useSessionActions({
|
|||
// a tab): offering "Open in new tab" again is noise.
|
||||
const alreadyTabbed = sessionId === selectedStoredSessionId || tiles.some(tile => tile.storedSessionId === sessionId)
|
||||
|
||||
const spec = (partial: Omit<ItemSpec, 'onSelect'> & { onSelect: () => void }): ItemSpec => partial
|
||||
const spec = (partial: Omit<ActionItemSpec, 'onSelect'> & { onSelect: () => void }): ActionItemSpec => partial
|
||||
|
||||
// OPEN — where else this session can go. A tab surface IS a tab already,
|
||||
// so it only offers the window hop (and its own Close, below).
|
||||
const openItems: ItemSpec[] = [
|
||||
const openItems: ActionItemSpec[] = [
|
||||
...(surface === 'row' && !alreadyTabbed
|
||||
? [
|
||||
spec({
|
||||
|
|
@ -248,7 +192,7 @@ function useSessionActions({
|
|||
]
|
||||
|
||||
// IDENTITY — name/mark/reference the session.
|
||||
const identityItems: ItemSpec[] = [
|
||||
const identityItems: ActionItemSpec[] = [
|
||||
spec({
|
||||
disabled: !sessionId,
|
||||
icon: 'edit',
|
||||
|
|
@ -270,7 +214,7 @@ function useSessionActions({
|
|||
]
|
||||
|
||||
// WORK — derive/extract from the session.
|
||||
const workItems: ItemSpec[] = [
|
||||
const workItems: ActionItemSpec[] = [
|
||||
spec({
|
||||
disabled: !onBranch,
|
||||
// Fork glyph to match the inline message action's GitFork icon
|
||||
|
|
@ -297,7 +241,7 @@ function useSessionActions({
|
|||
// TAB — close verbs that act on the strip (tabs only; a row isn't a tab).
|
||||
const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null
|
||||
|
||||
const tabCloseItems: ItemSpec[] =
|
||||
const tabCloseItems: ActionItemSpec[] =
|
||||
surface === 'tab'
|
||||
? [
|
||||
...(onClose
|
||||
|
|
@ -348,7 +292,7 @@ function useSessionActions({
|
|||
: []
|
||||
|
||||
// DANGER — put it away / destroy it (delete stays last, destructive-red).
|
||||
const dangerItems: ItemSpec[] = [
|
||||
const dangerItems: ActionItemSpec[] = [
|
||||
spec({
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
|
|
@ -371,18 +315,11 @@ function useSessionActions({
|
|||
}
|
||||
]
|
||||
|
||||
const renderMenuItem = (Item: MenuItem, { className, disabled, icon, label, onSelect, variant }: ItemSpec) => (
|
||||
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
<span>{label}</span>
|
||||
</Item>
|
||||
)
|
||||
|
||||
const renderItems = (kit: MenuKit) => (
|
||||
<>
|
||||
{openItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{openItems.map(item => renderActionItem(kit, item))}
|
||||
{openItems.length > 0 && <kit.Separator />}
|
||||
{identityItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{identityItems.map(item => renderActionItem(kit, item))}
|
||||
<kit.Sub>
|
||||
<kit.SubTrigger disabled={!sessionId}>
|
||||
<Codicon name="symbol-color" size="0.875rem" />
|
||||
|
|
@ -393,7 +330,7 @@ function useSessionActions({
|
|||
</kit.SubContent>
|
||||
</kit.Sub>
|
||||
<CopyButton
|
||||
appearance={kit.Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
|
||||
appearance={kit.copyAppearance}
|
||||
disabled={!sessionId}
|
||||
errorMessage={r.copyIdFailed}
|
||||
iconClassName="size-3.5 text-current"
|
||||
|
|
@ -403,19 +340,19 @@ function useSessionActions({
|
|||
text={sessionId}
|
||||
/>
|
||||
<kit.Separator />
|
||||
{workItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{workItems.map(item => renderActionItem(kit, item))}
|
||||
{tabCloseItems.length > 0 && (
|
||||
<>
|
||||
<kit.Separator />
|
||||
{tabCloseItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{tabCloseItems.map(item => renderActionItem(kit, item))}
|
||||
</>
|
||||
)}
|
||||
<kit.Separator />
|
||||
{dangerItems.map(item => renderMenuItem(kit.Item, item))}
|
||||
{dangerItems.map(item => renderActionItem(kit, item))}
|
||||
{onHideTabBar && (
|
||||
<>
|
||||
<kit.Separator />
|
||||
{renderMenuItem(kit.Item, {
|
||||
{renderActionItem(kit, {
|
||||
disabled: false,
|
||||
icon: 'eye-closed',
|
||||
label: r.hideTabBar,
|
||||
|
|
@ -443,13 +380,9 @@ function useSessionActions({
|
|||
}
|
||||
|
||||
interface SessionActionsMenuProps
|
||||
extends SessionActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
|
||||
extends SessionActions, Pick<React.ComponentProps<typeof ActionsMenu>, 'align' | 'sideOffset'> {
|
||||
children: React.ReactNode
|
||||
/** Tooltip label for the trigger. Composed INSIDE the dropdown trigger
|
||||
* (Tip wraps DropdownMenuTrigger, not the other way around) — Tip doesn't
|
||||
* forward the extra props/ref an `asChild` clone injects, so putting it as
|
||||
* the trigger's direct child silently drops onClick/aria-haspopup/ref and
|
||||
* the menu stops opening (#67500). */
|
||||
/** Tooltip label for the trigger. */
|
||||
tooltip?: React.ReactNode
|
||||
}
|
||||
|
||||
|
|
@ -462,23 +395,19 @@ export function SessionActionsMenu({
|
|||
}: SessionActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<Tip label={tooltip}>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={t.sidebar.row.actionsFor(actions.title)}
|
||||
className="w-40"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
{renderItems(DROPDOWN_KIT)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ActionsMenu
|
||||
align={align}
|
||||
ariaLabel={t.sidebar.row.actionsFor(actions.title)}
|
||||
contentClassName="w-40"
|
||||
items={renderItems}
|
||||
sideOffset={sideOffset}
|
||||
tooltip={tooltip}
|
||||
>
|
||||
{children}
|
||||
</ActionsMenu>
|
||||
{renameDialog}
|
||||
</>
|
||||
)
|
||||
|
|
@ -494,12 +423,13 @@ export function SessionContextMenu({ children, ...actions }: SessionContextMenuP
|
|||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
|
||||
{renderItems(CONTEXT_KIT)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<ActionsContextMenu
|
||||
ariaLabel={t.sidebar.row.actionsFor(actions.title)}
|
||||
contentClassName="w-40"
|
||||
items={renderItems}
|
||||
>
|
||||
{children}
|
||||
</ActionsContextMenu>
|
||||
{renameDialog}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,15 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
import { type ActionItemSpec, ActionsContextMenu, ActionsMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ExternalLink, Eye, EyeOff, KeyRound, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EnvVarActionsMenuProps extends Pick<
|
||||
React.ComponentProps<typeof DropdownMenuContent>,
|
||||
'align' | 'sideOffset'
|
||||
> {
|
||||
children: React.ReactNode
|
||||
interface EnvVarActions {
|
||||
clearDisabled?: boolean
|
||||
docsUrl?: string | null
|
||||
isRevealed?: boolean
|
||||
|
|
@ -35,21 +25,19 @@ interface EnvVarActionsMenuProps extends Pick<
|
|||
showReveal?: boolean
|
||||
}
|
||||
|
||||
export function EnvVarActionsMenu({
|
||||
align = 'end',
|
||||
children,
|
||||
// The shared action rows, rendered identically by the kebab dropdown and the
|
||||
// row's right-click menu so the two never drift.
|
||||
function useEnvVarItems({
|
||||
clearDisabled = false,
|
||||
docsUrl,
|
||||
isRevealed = false,
|
||||
isSet,
|
||||
label,
|
||||
onClear,
|
||||
onEdit,
|
||||
onManageKeys,
|
||||
onReveal,
|
||||
showReveal = true,
|
||||
sideOffset = 6
|
||||
}: EnvVarActionsMenuProps) {
|
||||
showReveal = true
|
||||
}: EnvVarActions) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.envActions
|
||||
const hasClear = isSet && onClear
|
||||
|
|
@ -57,75 +45,117 @@ export function EnvVarActionsMenu({
|
|||
const hasManageKeys = isSet && onManageKeys
|
||||
const hasDocs = Boolean(docsUrl?.trim())
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} aria-label={copy.actionsFor(label)} className="w-44" sideOffset={sideOffset}>
|
||||
{hasDocs && (
|
||||
<DropdownMenuItem
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
triggerHaptic('selection')
|
||||
window.open(docsUrl!, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
<span>{copy.docs}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
return (kit: MenuKit) => {
|
||||
const rows: ActionItemSpec[] = []
|
||||
|
||||
{hasReveal && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onReveal()
|
||||
}}
|
||||
>
|
||||
{isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
<span>{isRevealed ? copy.hideValue : copy.revealValue}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
if (hasDocs) {
|
||||
rows.push({
|
||||
iconNode: <ExternalLink className="size-3.5" />,
|
||||
key: 'docs',
|
||||
label: copy.docs,
|
||||
onSelect: event => {
|
||||
event.preventDefault()
|
||||
triggerHaptic('selection')
|
||||
window.open(docsUrl!, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{isSet ? copy.replace : copy.set}</span>
|
||||
</DropdownMenuItem>
|
||||
if (hasReveal) {
|
||||
rows.push({
|
||||
iconNode: isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />,
|
||||
key: 'reveal',
|
||||
label: isRevealed ? copy.hideValue : copy.revealValue,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onReveal()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
{hasManageKeys && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onManageKeys()
|
||||
}}
|
||||
>
|
||||
<KeyRound className="size-3.5" />
|
||||
<span>{copy.manageInKeys}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
rows.push({
|
||||
icon: 'edit',
|
||||
key: 'edit',
|
||||
label: isSet ? copy.replace : copy.set,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onEdit()
|
||||
}
|
||||
})
|
||||
|
||||
if (hasManageKeys) {
|
||||
rows.push({
|
||||
iconNode: <KeyRound className="size-3.5" />,
|
||||
key: 'manage-keys',
|
||||
label: copy.manageInKeys,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onManageKeys()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.map(row => renderActionItem(kit, row))}
|
||||
{hasClear && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={clearDisabled}
|
||||
onSelect={() => {
|
||||
<kit.Separator />
|
||||
{renderActionItem(kit, {
|
||||
disabled: clearDisabled,
|
||||
iconNode: <Trash2 className="size-3.5" />,
|
||||
key: 'clear',
|
||||
label: copy.clear,
|
||||
onSelect: () => {
|
||||
triggerHaptic('warning')
|
||||
onClear()
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span>{copy.clear}</span>
|
||||
</DropdownMenuItem>
|
||||
},
|
||||
variant: 'destructive'
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface EnvVarActionsMenuProps
|
||||
extends EnvVarActions, Pick<React.ComponentProps<typeof ActionsMenu>, 'align' | 'sideOffset'> {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function EnvVarActionsMenu({ align = 'end', children, sideOffset = 6, ...actions }: EnvVarActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.envActions
|
||||
const items = useEnvVarItems(actions)
|
||||
|
||||
return (
|
||||
<ActionsMenu
|
||||
align={align}
|
||||
ariaLabel={copy.actionsFor(actions.label)}
|
||||
contentClassName="w-44"
|
||||
items={items}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
{children}
|
||||
</ActionsMenu>
|
||||
)
|
||||
}
|
||||
|
||||
interface EnvVarContextMenuProps extends EnvVarActions {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Wrap an env-var row so right-clicking it opens the same menu as its kebab. */
|
||||
export function EnvVarContextMenu({ children, ...actions }: EnvVarContextMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.envActions
|
||||
const items = useEnvVarItems(actions)
|
||||
|
||||
return (
|
||||
<ActionsContextMenu ariaLabel={copy.actionsFor(actions.label)} contentClassName="w-44" items={items}>
|
||||
{children}
|
||||
</ActionsContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import type {
|
|||
ToolsetModelsResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
|
||||
import { EnvVarActionsMenu, EnvVarActionsTrigger, EnvVarContextMenu } from './env-var-actions-menu'
|
||||
import { Pill } from './primitives'
|
||||
import { VoiceProviderFields } from './voice-provider-fields'
|
||||
|
||||
|
|
@ -150,64 +150,68 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const actionProps = {
|
||||
clearDisabled: busy,
|
||||
docsUrl: envVar.url,
|
||||
isRevealed: revealed !== null,
|
||||
isSet,
|
||||
label: envVar.key,
|
||||
onClear: () => void handleClear(),
|
||||
onEdit: () => setEditing(true),
|
||||
onManageKeys: openInKeys,
|
||||
onReveal: () => void handleReveal()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium">{envVar.key}</span>
|
||||
<Pill tone={isSet ? 'primary' : 'muted'}>
|
||||
{isSet && <Check className="size-3" />}
|
||||
{isSet ? copy.set : copy.notSet}
|
||||
</Pill>
|
||||
<EnvVarContextMenu {...actionProps}>
|
||||
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium">{envVar.key}</span>
|
||||
<Pill tone={isSet ? 'primary' : 'muted'}>
|
||||
{isSet && <Check className="size-3" />}
|
||||
{isSet ? copy.set : copy.notSet}
|
||||
</Pill>
|
||||
</div>
|
||||
{envVar.prompt && envVar.prompt !== envVar.key && (
|
||||
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
|
||||
)}
|
||||
</div>
|
||||
{envVar.prompt && envVar.prompt !== envVar.key && (
|
||||
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
|
||||
{!editing && (
|
||||
<EnvVarActionsMenu {...actionProps}>
|
||||
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
|
||||
</EnvVarActionsMenu>
|
||||
)}
|
||||
</div>
|
||||
{!editing && (
|
||||
<EnvVarActionsMenu
|
||||
clearDisabled={busy}
|
||||
docsUrl={envVar.url}
|
||||
isRevealed={revealed !== null}
|
||||
isSet={isSet}
|
||||
label={envVar.key}
|
||||
onClear={() => void handleClear()}
|
||||
onEdit={() => setEditing(true)}
|
||||
onManageKeys={openInKeys}
|
||||
onReveal={() => void handleReveal()}
|
||||
>
|
||||
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
|
||||
</EnvVarActionsMenu>
|
||||
|
||||
{isSet && revealed !== null && (
|
||||
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">
|
||||
{revealed || '---'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className="min-w-52 flex-1 font-mono"
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder={envVar.prompt || envVar.key}
|
||||
type={envVar.default ? 'text' : 'password'}
|
||||
value={value}
|
||||
/>
|
||||
<Button disabled={busy || !value} onClick={() => void handleSave()} size="sm">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||
{t.common.save}
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(false)} size="sm" variant="text">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSet && revealed !== null && (
|
||||
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">
|
||||
{revealed || '---'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className="min-w-52 flex-1 font-mono"
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder={envVar.prompt || envVar.key}
|
||||
type={envVar.default ? 'text' : 'password'}
|
||||
value={value}
|
||||
/>
|
||||
<Button disabled={busy || !value} onClick={() => void handleSave()} size="sm">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||
{t.common.save}
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(false)} size="sm" variant="text">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</EnvVarContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import { type KeyboardEvent, type MouseEvent, type ReactNode, type Ref } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -15,6 +15,10 @@ interface StatusRowProps {
|
|||
/** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */
|
||||
trailing?: ReactNode
|
||||
trailingVisible?: boolean
|
||||
/** Forwarded to the row's root — lets a wrapper (e.g. a context-menu trigger
|
||||
* using `asChild`) attach `ref` / `onContextMenu` to the real DOM node. */
|
||||
ref?: Ref<HTMLDivElement>
|
||||
onContextMenu?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -29,6 +33,8 @@ export function StatusRow({
|
|||
className,
|
||||
leading,
|
||||
onActivate,
|
||||
onContextMenu,
|
||||
ref,
|
||||
trailing,
|
||||
trailingVisible = false
|
||||
}: StatusRowProps) {
|
||||
|
|
@ -41,6 +47,7 @@ export function StatusRow({
|
|||
className
|
||||
)}
|
||||
onClick={onActivate}
|
||||
onContextMenu={onContextMenu}
|
||||
onKeyDown={
|
||||
onActivate
|
||||
? event => {
|
||||
|
|
@ -51,6 +58,7 @@ export function StatusRow({
|
|||
}
|
||||
: undefined
|
||||
}
|
||||
ref={ref}
|
||||
role={onActivate ? 'button' : undefined}
|
||||
tabIndex={onActivate ? 0 : undefined}
|
||||
>
|
||||
|
|
|
|||
177
apps/desktop/src/components/ui/actions-menu.tsx
Normal file
177
apps/desktop/src/components/ui/actions-menu.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
|
||||
// One place to define a set of actions and get BOTH a kebab dropdown and a
|
||||
// matching right-click context menu — so a row's ⋯ menu and its right-click menu
|
||||
// never drift. The dropdown and context primitives share an identical item
|
||||
// surface (Item / Separator / Sub…), so a caller writes `items={kit => …}` once
|
||||
// and hands the render function to both wrappers.
|
||||
//
|
||||
// The pattern originated inline in the session row menu; it lives here so every
|
||||
// kebab in the app can add right-click parity in one line.
|
||||
|
||||
/** A menu flavour (dropdown / context) — the item + separator + submenu parts. */
|
||||
export interface MenuKit {
|
||||
Item: typeof DropdownMenuItem | typeof ContextMenuItem
|
||||
Label: typeof DropdownMenuLabel | typeof ContextMenuLabel
|
||||
Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator
|
||||
Sub: typeof DropdownMenuSub | typeof ContextMenuSub
|
||||
SubTrigger: typeof DropdownMenuSubTrigger | typeof ContextMenuSubTrigger
|
||||
SubContent: typeof DropdownMenuSubContent | typeof ContextMenuSubContent
|
||||
/** `CopyButton`'s `appearance` for this flavour — pass to a menu-item copy. */
|
||||
copyAppearance: 'context-menu-item' | 'menu-item'
|
||||
}
|
||||
|
||||
export const DROPDOWN_KIT: MenuKit = {
|
||||
Item: DropdownMenuItem,
|
||||
Label: DropdownMenuLabel,
|
||||
Separator: DropdownMenuSeparator,
|
||||
Sub: DropdownMenuSub,
|
||||
SubContent: DropdownMenuSubContent,
|
||||
SubTrigger: DropdownMenuSubTrigger,
|
||||
copyAppearance: 'menu-item'
|
||||
}
|
||||
|
||||
export const CONTEXT_KIT: MenuKit = {
|
||||
Item: ContextMenuItem,
|
||||
Label: ContextMenuLabel,
|
||||
Separator: ContextMenuSeparator,
|
||||
Sub: ContextMenuSub,
|
||||
SubContent: ContextMenuSubContent,
|
||||
SubTrigger: ContextMenuSubTrigger,
|
||||
copyAppearance: 'context-menu-item'
|
||||
}
|
||||
|
||||
/** A single action row. Provide `icon` (codicon name) or `iconNode` (any node). */
|
||||
export interface ActionItemSpec {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
icon?: string
|
||||
iconNode?: React.ReactNode
|
||||
/** Stable key; defaults to `label` when it's a string. */
|
||||
key?: string
|
||||
label: React.ReactNode
|
||||
onSelect: (event: Event) => void
|
||||
variant?: 'default' | 'destructive'
|
||||
}
|
||||
|
||||
/** Render one `ActionItemSpec` with the given kit's Item component. */
|
||||
export function renderActionItem(kit: MenuKit, { className, disabled, icon, iconNode, key, label, onSelect, variant }: ActionItemSpec) {
|
||||
return (
|
||||
<kit.Item
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
key={key ?? (typeof label === 'string' ? label : undefined)}
|
||||
onSelect={onSelect}
|
||||
variant={variant}
|
||||
>
|
||||
{iconNode ?? (icon ? <Codicon name={icon} size="0.875rem" /> : null)}
|
||||
{typeof label === 'string' ? <span>{label}</span> : label}
|
||||
</kit.Item>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActionsMenuProps
|
||||
extends Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'side' | 'sideOffset'> {
|
||||
/** The trigger (a kebab button). Wrapped in `DropdownMenuTrigger asChild`. */
|
||||
children: React.ReactNode
|
||||
/** The action rows, rendered with `DROPDOWN_KIT`. Share this with `ActionsContextMenu`. */
|
||||
items: (kit: MenuKit) => React.ReactNode
|
||||
ariaLabel?: string
|
||||
contentClassName?: string
|
||||
/** Optional tooltip on the trigger (composed INSIDE the asChild chain). */
|
||||
tooltip?: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A kebab dropdown menu. Pair it with `ActionsContextMenu` using the same
|
||||
* `items` render function so the two menus stay identical.
|
||||
*/
|
||||
export function ActionsMenu({
|
||||
align = 'end',
|
||||
ariaLabel,
|
||||
children,
|
||||
contentClassName,
|
||||
items,
|
||||
onOpenChange,
|
||||
open,
|
||||
side,
|
||||
sideOffset = 6,
|
||||
tooltip
|
||||
}: ActionsMenuProps) {
|
||||
// Tip wraps the trigger, not the reverse: Tip doesn't forward the ref/props an
|
||||
// `asChild` clone injects, so a Tip placed as the trigger's child silently
|
||||
// drops onClick/aria-haspopup and the menu stops opening (#67500).
|
||||
const trigger = <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange} open={open}>
|
||||
{tooltip ? <Tip label={tooltip}>{trigger}</Tip> : trigger}
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={ariaLabel}
|
||||
className={contentClassName}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
{items(DROPDOWN_KIT)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActionsContextMenuProps {
|
||||
/** The area that receives right-click. Wrapped in `ContextMenuTrigger asChild`. */
|
||||
children: React.ReactNode
|
||||
/** The action rows, rendered with `CONTEXT_KIT`. Share this with `ActionsMenu`. */
|
||||
items: (kit: MenuKit) => React.ReactNode
|
||||
ariaLabel?: string
|
||||
contentClassName?: string
|
||||
/** Skip the wrapper (render children bare) — e.g. nothing is actionable yet. */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a row so right-clicking it opens the same menu as its kebab. Pass the
|
||||
* kebab's `items` render function so both surfaces mirror each other.
|
||||
*/
|
||||
export function ActionsContextMenu({ ariaLabel, children, contentClassName, disabled, items }: ActionsContextMenuProps) {
|
||||
if (disabled) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent aria-label={ariaLabel} className={contentClassName}>
|
||||
{items(CONTEXT_KIT)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue