Skip to content

Commit ea1aba4

Browse files
committed
feat(app): project context menu on right-click
1 parent b9aad20 commit ea1aba4

4 files changed

Lines changed: 521 additions & 29 deletions

File tree

packages/app/src/pages/layout.tsx

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
3131
import { HoverCard } from "@opencode-ai/ui/hover-card"
3232
import { MessageNav } from "@opencode-ai/ui/message-nav"
3333
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
34+
import { ContextMenu } from "@opencode-ai/ui/context-menu"
3435
import { Collapsible } from "@opencode-ai/ui/collapsible"
3536
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
3637
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -2310,10 +2311,13 @@ export default function Layout(props: ParentProps) {
23102311
() => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(),
23112312
)
23122313
const [open, setOpen] = createSignal(false)
2314+
const [menu, setMenu] = createSignal(false)
23132315

23142316
const preview = createMemo(() => !props.mobile && layout.sidebar.opened())
23152317
const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened())
2316-
const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree))
2318+
const active = createMemo(
2319+
() => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree),
2320+
)
23172321

23182322
createEffect(() => {
23192323
if (preview()) return
@@ -2352,49 +2356,94 @@ export default function Layout(props: ParentProps) {
23522356

23532357
const projectName = () => props.project.name || getFilename(props.project.worktree)
23542358
const trigger = (
2355-
<button
2356-
type="button"
2357-
aria-label={projectName()}
2358-
data-action="project-switch"
2359-
data-project={base64Encode(props.project.worktree)}
2360-
classList={{
2361-
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
2362-
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
2363-
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
2364-
!selected() && !active(),
2365-
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
2366-
}}
2367-
onMouseEnter={() => {
2368-
if (!overlay()) return
2369-
globalSync.child(props.project.worktree)
2370-
setState("hoverProject", props.project.worktree)
2371-
setState("hoverSession", undefined)
2359+
<ContextMenu
2360+
modal={!sidebarHovering()}
2361+
onOpenChange={(value) => {
2362+
setMenu(value)
2363+
if (value) setOpen(false)
23722364
}}
2373-
onFocus={() => {
2374-
if (!overlay()) return
2375-
globalSync.child(props.project.worktree)
2376-
setState("hoverProject", props.project.worktree)
2377-
setState("hoverSession", undefined)
2378-
}}
2379-
onClick={() => navigateToProject(props.project.worktree)}
2380-
onBlur={() => setOpen(false)}
23812365
>
2382-
<ProjectIcon project={props.project} notify />
2383-
</button>
2366+
<ContextMenu.Trigger
2367+
as="button"
2368+
type="button"
2369+
aria-label={projectName()}
2370+
data-action="project-switch"
2371+
data-project={base64Encode(props.project.worktree)}
2372+
classList={{
2373+
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
2374+
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
2375+
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
2376+
!selected() && !active(),
2377+
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
2378+
}}
2379+
onMouseEnter={() => {
2380+
if (!overlay()) return
2381+
globalSync.child(props.project.worktree)
2382+
setState("hoverProject", props.project.worktree)
2383+
setState("hoverSession", undefined)
2384+
}}
2385+
onFocus={() => {
2386+
if (!overlay()) return
2387+
globalSync.child(props.project.worktree)
2388+
setState("hoverProject", props.project.worktree)
2389+
setState("hoverSession", undefined)
2390+
}}
2391+
onClick={() => navigateToProject(props.project.worktree)}
2392+
onBlur={() => setOpen(false)}
2393+
>
2394+
<ProjectIcon project={props.project} notify />
2395+
</ContextMenu.Trigger>
2396+
<ContextMenu.Portal mount={!props.mobile ? state.nav : undefined}>
2397+
<ContextMenu.Content>
2398+
<ContextMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}>
2399+
<ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
2400+
</ContextMenu.Item>
2401+
<ContextMenu.Item
2402+
data-action="project-workspaces-toggle"
2403+
data-project={base64Encode(props.project.worktree)}
2404+
disabled={props.project.vcs !== "git" && !layout.sidebar.workspaces(props.project.worktree)()}
2405+
onSelect={() => {
2406+
const enabled = layout.sidebar.workspaces(props.project.worktree)()
2407+
if (enabled) {
2408+
layout.sidebar.toggleWorkspaces(props.project.worktree)
2409+
return
2410+
}
2411+
if (props.project.vcs !== "git") return
2412+
layout.sidebar.toggleWorkspaces(props.project.worktree)
2413+
}}
2414+
>
2415+
<ContextMenu.ItemLabel>
2416+
{layout.sidebar.workspaces(props.project.worktree)()
2417+
? language.t("sidebar.workspaces.disable")
2418+
: language.t("sidebar.workspaces.enable")}
2419+
</ContextMenu.ItemLabel>
2420+
</ContextMenu.Item>
2421+
<ContextMenu.Separator />
2422+
<ContextMenu.Item
2423+
data-action="project-close-menu"
2424+
data-project={base64Encode(props.project.worktree)}
2425+
onSelect={() => closeProject(props.project.worktree)}
2426+
>
2427+
<ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
2428+
</ContextMenu.Item>
2429+
</ContextMenu.Content>
2430+
</ContextMenu.Portal>
2431+
</ContextMenu>
23842432
)
23852433

23862434
return (
23872435
// @ts-ignore
23882436
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
23892437
<Show when={preview()} fallback={trigger}>
23902438
<HoverCard
2391-
open={open()}
2439+
open={open() && !menu()}
23922440
openDelay={0}
23932441
closeDelay={0}
23942442
placement="right-start"
23952443
gutter={6}
23962444
trigger={trigger}
23972445
onOpenChange={(value) => {
2446+
if (menu()) return
23982447
setOpen(value)
23992448
if (value) setState("hoverSession", undefined)
24002449
}}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
[data-component="context-menu-content"],
2+
[data-component="context-menu-sub-content"] {
3+
min-width: 8rem;
4+
overflow: hidden;
5+
border: none;
6+
border-radius: var(--radius-md);
7+
box-shadow: var(--shadow-xs-border);
8+
background-clip: padding-box;
9+
background-color: var(--surface-raised-stronger-non-alpha);
10+
padding: 4px;
11+
z-index: 100;
12+
transform-origin: var(--kb-menu-content-transform-origin);
13+
14+
&:focus-within,
15+
&:focus {
16+
outline: none;
17+
}
18+
19+
animation: contextMenuContentHide var(--transition-duration) var(--transition-easing) forwards;
20+
21+
@starting-style {
22+
animation: none;
23+
}
24+
25+
&[data-expanded] {
26+
pointer-events: auto;
27+
animation: contextMenuContentShow var(--transition-duration) var(--transition-easing) forwards;
28+
}
29+
}
30+
31+
[data-component="context-menu-content"],
32+
[data-component="context-menu-sub-content"] {
33+
[data-slot="context-menu-item"],
34+
[data-slot="context-menu-checkbox-item"],
35+
[data-slot="context-menu-radio-item"],
36+
[data-slot="context-menu-sub-trigger"] {
37+
position: relative;
38+
display: flex;
39+
align-items: center;
40+
gap: 8px;
41+
padding: 4px 8px;
42+
border-radius: var(--radius-sm);
43+
cursor: default;
44+
outline: none;
45+
46+
font-family: var(--font-family-sans);
47+
font-size: var(--font-size-base);
48+
font-weight: var(--font-weight-medium);
49+
line-height: var(--line-height-large);
50+
letter-spacing: var(--letter-spacing-normal);
51+
color: var(--text-strong);
52+
53+
transition-property: background-color, color;
54+
transition-duration: var(--transition-duration);
55+
transition-timing-function: var(--transition-easing);
56+
user-select: none;
57+
58+
&:hover {
59+
background-color: var(--surface-raised-base-hover);
60+
}
61+
62+
&[data-disabled] {
63+
color: var(--text-weak);
64+
pointer-events: none;
65+
}
66+
}
67+
68+
[data-slot="context-menu-sub-trigger"] {
69+
&[data-expanded] {
70+
background: var(--surface-raised-base-hover);
71+
outline: none;
72+
border: none;
73+
}
74+
}
75+
76+
[data-slot="context-menu-item-indicator"] {
77+
display: flex;
78+
align-items: center;
79+
justify-content: center;
80+
width: 16px;
81+
height: 16px;
82+
}
83+
84+
[data-slot="context-menu-item-label"] {
85+
flex: 1;
86+
}
87+
88+
[data-slot="context-menu-item-description"] {
89+
font-size: var(--font-size-x-small);
90+
color: var(--text-weak);
91+
}
92+
93+
[data-slot="context-menu-separator"] {
94+
height: 1px;
95+
margin: 4px -4px;
96+
border-top-color: var(--border-weak-base);
97+
}
98+
99+
[data-slot="context-menu-group-label"] {
100+
padding: 4px 8px;
101+
font-family: var(--font-family-sans);
102+
font-size: var(--font-size-x-small);
103+
font-weight: var(--font-weight-medium);
104+
line-height: var(--line-height-large);
105+
letter-spacing: var(--letter-spacing-normal);
106+
color: var(--text-weak);
107+
}
108+
109+
[data-slot="context-menu-arrow"] {
110+
fill: var(--surface-raised-stronger-non-alpha);
111+
}
112+
}
113+
114+
@keyframes contextMenuContentShow {
115+
from {
116+
opacity: 0;
117+
transform: scaleY(0.95);
118+
}
119+
to {
120+
opacity: 1;
121+
transform: scaleY(1);
122+
}
123+
}
124+
125+
@keyframes contextMenuContentHide {
126+
from {
127+
opacity: 1;
128+
transform: scaleY(1);
129+
}
130+
to {
131+
opacity: 0;
132+
transform: scaleY(0.95);
133+
}
134+
}

0 commit comments

Comments
 (0)