Browse Source

feat(app): project context menu on right-click

Adam 3 weeks ago
parent
commit
ea1aba4192

+ 78 - 29
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)
             }}

+ 134 - 0
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);
+  }
+}

+ 308 - 0
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,
+})

+ 1 - 0
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);