summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-02 07:02:40 -0600
committerAdam <[email protected]>2026-02-02 14:24:22 -0600
commitea1aba4192fd356603e807144edf202328008ee6 (patch)
treee8d57fb47b8d288f884cbcab9d9b7ea13d0f4ded
parentb9aad20be651050880bf2bc3b4c857f16a970402 (diff)
downloadopencode-ea1aba4192fd356603e807144edf202328008ee6.tar.gz
opencode-ea1aba4192fd356603e807144edf202328008ee6.zip
feat(app): project context menu on right-click
-rw-r--r--packages/app/src/pages/layout.tsx107
-rw-r--r--packages/ui/src/components/context-menu.css134
-rw-r--r--packages/ui/src/components/context-menu.tsx308
-rw-r--r--packages/ui/src/styles/index.css1
4 files changed, 521 insertions, 29 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index a970bf667..5a8dc0f2e 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -31,6 +31,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -2310,10 +2311,13 @@ export default function Layout(props: ParentProps) {
() => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(),
)
const [open, setOpen] = createSignal(false)
+ const [menu, setMenu] = createSignal(false)
const preview = createMemo(() => !props.mobile && layout.sidebar.opened())
const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened())
- const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree))
+ const active = createMemo(
+ () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree),
+ )
createEffect(() => {
if (preview()) return
@@ -2352,35 +2356,79 @@ export default function Layout(props: ParentProps) {
const projectName = () => props.project.name || getFilename(props.project.worktree)
const trigger = (
- <button
- type="button"
- aria-label={projectName()}
- data-action="project-switch"
- data-project={base64Encode(props.project.worktree)}
- classList={{
- "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
- "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
- "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
- !selected() && !active(),
- "bg-surface-base-hover border border-border-weak-base": !selected() && active(),
- }}
- onMouseEnter={() => {
- if (!overlay()) return
- globalSync.child(props.project.worktree)
- setState("hoverProject", props.project.worktree)
- setState("hoverSession", undefined)
+ <ContextMenu
+ modal={!sidebarHovering()}
+ onOpenChange={(value) => {
+ setMenu(value)
+ if (value) setOpen(false)
}}
- onFocus={() => {
- if (!overlay()) return
- globalSync.child(props.project.worktree)
- setState("hoverProject", props.project.worktree)
- setState("hoverSession", undefined)
- }}
- onClick={() => navigateToProject(props.project.worktree)}
- onBlur={() => setOpen(false)}
>
- <ProjectIcon project={props.project} notify />
- </button>
+ <ContextMenu.Trigger
+ as="button"
+ type="button"
+ aria-label={projectName()}
+ data-action="project-switch"
+ data-project={base64Encode(props.project.worktree)}
+ classList={{
+ "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
+ "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
+ "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
+ !selected() && !active(),
+ "bg-surface-base-hover border border-border-weak-base": !selected() && active(),
+ }}
+ onMouseEnter={() => {
+ if (!overlay()) return
+ globalSync.child(props.project.worktree)
+ setState("hoverProject", props.project.worktree)
+ setState("hoverSession", undefined)
+ }}
+ onFocus={() => {
+ if (!overlay()) return
+ globalSync.child(props.project.worktree)
+ setState("hoverProject", props.project.worktree)
+ setState("hoverSession", undefined)
+ }}
+ onClick={() => navigateToProject(props.project.worktree)}
+ onBlur={() => setOpen(false)}
+ >
+ <ProjectIcon project={props.project} notify />
+ </ContextMenu.Trigger>
+ <ContextMenu.Portal mount={!props.mobile ? state.nav : undefined}>
+ <ContextMenu.Content>
+ <ContextMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}>
+ <ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ <ContextMenu.Item
+ data-action="project-workspaces-toggle"
+ data-project={base64Encode(props.project.worktree)}
+ disabled={props.project.vcs !== "git" && !layout.sidebar.workspaces(props.project.worktree)()}
+ onSelect={() => {
+ const enabled = layout.sidebar.workspaces(props.project.worktree)()
+ if (enabled) {
+ layout.sidebar.toggleWorkspaces(props.project.worktree)
+ return
+ }
+ if (props.project.vcs !== "git") return
+ layout.sidebar.toggleWorkspaces(props.project.worktree)
+ }}
+ >
+ <ContextMenu.ItemLabel>
+ {layout.sidebar.workspaces(props.project.worktree)()
+ ? language.t("sidebar.workspaces.disable")
+ : language.t("sidebar.workspaces.enable")}
+ </ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ <ContextMenu.Separator />
+ <ContextMenu.Item
+ data-action="project-close-menu"
+ data-project={base64Encode(props.project.worktree)}
+ onSelect={() => closeProject(props.project.worktree)}
+ >
+ <ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ </ContextMenu.Content>
+ </ContextMenu.Portal>
+ </ContextMenu>
)
return (
@@ -2388,13 +2436,14 @@ export default function Layout(props: ParentProps) {
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview()} fallback={trigger}>
<HoverCard
- open={open()}
+ open={open() && !menu()}
openDelay={0}
closeDelay={0}
placement="right-start"
gutter={6}
trigger={trigger}
onOpenChange={(value) => {
+ if (menu()) return
setOpen(value)
if (value) setState("hoverSession", undefined)
}}
diff --git a/packages/ui/src/components/context-menu.css b/packages/ui/src/components/context-menu.css
new file mode 100644
index 000000000..1e366dccd
--- /dev/null
+++ b/packages/ui/src/components/context-menu.css
@@ -0,0 +1,134 @@
+[data-component="context-menu-content"],
+[data-component="context-menu-sub-content"] {
+ min-width: 8rem;
+ overflow: hidden;
+ border: none;
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-xs-border);
+ background-clip: padding-box;
+ background-color: var(--surface-raised-stronger-non-alpha);
+ padding: 4px;
+ z-index: 100;
+ transform-origin: var(--kb-menu-content-transform-origin);
+
+ &:focus-within,
+ &:focus {
+ outline: none;
+ }
+
+ animation: contextMenuContentHide var(--transition-duration) var(--transition-easing) forwards;
+
+ @starting-style {
+ animation: none;
+ }
+
+ &[data-expanded] {
+ pointer-events: auto;
+ animation: contextMenuContentShow var(--transition-duration) var(--transition-easing) forwards;
+ }
+}
+
+[data-component="context-menu-content"],
+[data-component="context-menu-sub-content"] {
+ [data-slot="context-menu-item"],
+ [data-slot="context-menu-checkbox-item"],
+ [data-slot="context-menu-radio-item"],
+ [data-slot="context-menu-sub-trigger"] {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px;
+ border-radius: var(--radius-sm);
+ cursor: default;
+ outline: none;
+
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ color: var(--text-strong);
+
+ transition-property: background-color, color;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+ user-select: none;
+
+ &:hover {
+ background-color: var(--surface-raised-base-hover);
+ }
+
+ &[data-disabled] {
+ color: var(--text-weak);
+ pointer-events: none;
+ }
+ }
+
+ [data-slot="context-menu-sub-trigger"] {
+ &[data-expanded] {
+ background: var(--surface-raised-base-hover);
+ outline: none;
+ border: none;
+ }
+ }
+
+ [data-slot="context-menu-item-indicator"] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ }
+
+ [data-slot="context-menu-item-label"] {
+ flex: 1;
+ }
+
+ [data-slot="context-menu-item-description"] {
+ font-size: var(--font-size-x-small);
+ color: var(--text-weak);
+ }
+
+ [data-slot="context-menu-separator"] {
+ height: 1px;
+ margin: 4px -4px;
+ border-top-color: var(--border-weak-base);
+ }
+
+ [data-slot="context-menu-group-label"] {
+ padding: 4px 8px;
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-x-small);
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ color: var(--text-weak);
+ }
+
+ [data-slot="context-menu-arrow"] {
+ fill: var(--surface-raised-stronger-non-alpha);
+ }
+}
+
+@keyframes contextMenuContentShow {
+ from {
+ opacity: 0;
+ transform: scaleY(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: scaleY(1);
+ }
+}
+
+@keyframes contextMenuContentHide {
+ from {
+ opacity: 1;
+ transform: scaleY(1);
+ }
+ to {
+ opacity: 0;
+ transform: scaleY(0.95);
+ }
+}
diff --git a/packages/ui/src/components/context-menu.tsx b/packages/ui/src/components/context-menu.tsx
new file mode 100644
index 000000000..afdaff7b8
--- /dev/null
+++ b/packages/ui/src/components/context-menu.tsx
@@ -0,0 +1,308 @@
+import { ContextMenu as Kobalte } from "@kobalte/core/context-menu"
+import { splitProps } from "solid-js"
+import type { ComponentProps, ParentProps } from "solid-js"
+
+export interface ContextMenuProps extends ComponentProps<typeof Kobalte> {}
+export interface ContextMenuTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
+export interface ContextMenuIconProps extends ComponentProps<typeof Kobalte.Icon> {}
+export interface ContextMenuPortalProps extends ComponentProps<typeof Kobalte.Portal> {}
+export interface ContextMenuContentProps extends ComponentProps<typeof Kobalte.Content> {}
+export interface ContextMenuArrowProps extends ComponentProps<typeof Kobalte.Arrow> {}
+export interface ContextMenuSeparatorProps extends ComponentProps<typeof Kobalte.Separator> {}
+export interface ContextMenuGroupProps extends ComponentProps<typeof Kobalte.Group> {}
+export interface ContextMenuGroupLabelProps extends ComponentProps<typeof Kobalte.GroupLabel> {}
+export interface ContextMenuItemProps extends ComponentProps<typeof Kobalte.Item> {}
+export interface ContextMenuItemLabelProps extends ComponentProps<typeof Kobalte.ItemLabel> {}
+export interface ContextMenuItemDescriptionProps extends ComponentProps<typeof Kobalte.ItemDescription> {}
+export interface ContextMenuItemIndicatorProps extends ComponentProps<typeof Kobalte.ItemIndicator> {}
+export interface ContextMenuRadioGroupProps extends ComponentProps<typeof Kobalte.RadioGroup> {}
+export interface ContextMenuRadioItemProps extends ComponentProps<typeof Kobalte.RadioItem> {}
+export interface ContextMenuCheckboxItemProps extends ComponentProps<typeof Kobalte.CheckboxItem> {}
+export interface ContextMenuSubProps extends ComponentProps<typeof Kobalte.Sub> {}
+export interface ContextMenuSubTriggerProps extends ComponentProps<typeof Kobalte.SubTrigger> {}
+export interface ContextMenuSubContentProps extends ComponentProps<typeof Kobalte.SubContent> {}
+
+function ContextMenuRoot(props: ContextMenuProps) {
+ return <Kobalte {...props} data-component="context-menu" />
+}
+
+function ContextMenuTrigger(props: ParentProps<ContextMenuTriggerProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.Trigger
+ {...rest}
+ data-slot="context-menu-trigger"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.Trigger>
+ )
+}
+
+function ContextMenuIcon(props: ParentProps<ContextMenuIconProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.Icon
+ {...rest}
+ data-slot="context-menu-icon"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.Icon>
+ )
+}
+
+function ContextMenuPortal(props: ContextMenuPortalProps) {
+ return <Kobalte.Portal {...props} />
+}
+
+function ContextMenuContent(props: ParentProps<ContextMenuContentProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.Content
+ {...rest}
+ data-component="context-menu-content"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.Content>
+ )
+}
+
+function ContextMenuArrow(props: ContextMenuArrowProps) {
+ const [local, rest] = splitProps(props, ["class", "classList"])
+ return (
+ <Kobalte.Arrow
+ {...rest}
+ data-slot="context-menu-arrow"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ />
+ )
+}
+
+function ContextMenuSeparator(props: ContextMenuSeparatorProps) {
+ const [local, rest] = splitProps(props, ["class", "classList"])
+ return (
+ <Kobalte.Separator
+ {...rest}
+ data-slot="context-menu-separator"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ />
+ )
+}
+
+function ContextMenuGroup(props: ParentProps<ContextMenuGroupProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.Group
+ {...rest}
+ data-slot="context-menu-group"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.Group>
+ )
+}
+
+function ContextMenuGroupLabel(props: ParentProps<ContextMenuGroupLabelProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.GroupLabel
+ {...rest}
+ data-slot="context-menu-group-label"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.GroupLabel>
+ )
+}
+
+function ContextMenuItem(props: ParentProps<ContextMenuItemProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.Item
+ {...rest}
+ data-slot="context-menu-item"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.Item>
+ )
+}
+
+function ContextMenuItemLabel(props: ParentProps<ContextMenuItemLabelProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.ItemLabel
+ {...rest}
+ data-slot="context-menu-item-label"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.ItemLabel>
+ )
+}
+
+function ContextMenuItemDescription(props: ParentProps<ContextMenuItemDescriptionProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.ItemDescription
+ {...rest}
+ data-slot="context-menu-item-description"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.ItemDescription>
+ )
+}
+
+function ContextMenuItemIndicator(props: ParentProps<ContextMenuItemIndicatorProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.ItemIndicator
+ {...rest}
+ data-slot="context-menu-item-indicator"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.ItemIndicator>
+ )
+}
+
+function ContextMenuRadioGroup(props: ParentProps<ContextMenuRadioGroupProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.RadioGroup
+ {...rest}
+ data-slot="context-menu-radio-group"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.RadioGroup>
+ )
+}
+
+function ContextMenuRadioItem(props: ParentProps<ContextMenuRadioItemProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.RadioItem
+ {...rest}
+ data-slot="context-menu-radio-item"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.RadioItem>
+ )
+}
+
+function ContextMenuCheckboxItem(props: ParentProps<ContextMenuCheckboxItemProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.CheckboxItem
+ {...rest}
+ data-slot="context-menu-checkbox-item"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.CheckboxItem>
+ )
+}
+
+function ContextMenuSub(props: ContextMenuSubProps) {
+ return <Kobalte.Sub {...props} />
+}
+
+function ContextMenuSubTrigger(props: ParentProps<ContextMenuSubTriggerProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.SubTrigger
+ {...rest}
+ data-slot="context-menu-sub-trigger"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.SubTrigger>
+ )
+}
+
+function ContextMenuSubContent(props: ParentProps<ContextMenuSubContentProps>) {
+ const [local, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <Kobalte.SubContent
+ {...rest}
+ data-component="context-menu-sub-content"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.SubContent>
+ )
+}
+
+export const ContextMenu = Object.assign(ContextMenuRoot, {
+ Trigger: ContextMenuTrigger,
+ Icon: ContextMenuIcon,
+ Portal: ContextMenuPortal,
+ Content: ContextMenuContent,
+ Arrow: ContextMenuArrow,
+ Separator: ContextMenuSeparator,
+ Group: ContextMenuGroup,
+ GroupLabel: ContextMenuGroupLabel,
+ Item: ContextMenuItem,
+ ItemLabel: ContextMenuItemLabel,
+ ItemDescription: ContextMenuItemDescription,
+ ItemIndicator: ContextMenuItemIndicator,
+ RadioGroup: ContextMenuRadioGroup,
+ RadioItem: ContextMenuRadioItem,
+ CheckboxItem: ContextMenuCheckboxItem,
+ Sub: ContextMenuSub,
+ SubTrigger: ContextMenuSubTrigger,
+ SubContent: ContextMenuSubContent,
+})
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index 2a8171f98..d5939b2b3 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -16,6 +16,7 @@
@import "../components/collapsible.css" layer(components);
@import "../components/diff.css" layer(components);
@import "../components/diff-changes.css" layer(components);
+@import "../components/context-menu.css" layer(components);
@import "../components/dropdown-menu.css" layer(components);
@import "../components/dialog.css" layer(components);
@import "../components/file-icon.css" layer(components);