Add right-click menu to issue and patch lists

This commit is contained in:
Brandon Oxendine
2026-10-20 15:44:56 +02:00
committed by Rūdolfs Ošiņš
parent 95405a07cb
commit 41e5b96b09
16 changed files with 942 additions and 23 deletions
@@ -0,0 +1,48 @@
<script lang="ts">
import type { Author } from "@bindings/cob/Author";
import { publicKeyFromDid, truncateId } from "@app/lib/utils";
import MenuCheckItem from "@app/components/MenuCheckItem.svelte";
import UserAvatar from "@app/components/UserAvatar.svelte";
interface Props {
assignees: Author[];
candidates: Author[];
save: (assignees: Author[]) => void;
}
const { assignees, candidates, save }: Props = $props();
let selected: Author[] = $derived([...assignees]);
const options = $derived(
[...candidates, ...assignees].filter(
(author, index, all) =>
all.findIndex(other => other.did === author.did) === index,
),
);
function isSelected(author: Author): boolean {
return selected.some(other => other.did === author.did);
}
function toggle(author: Author) {
selected = isSelected(author)
? selected.filter(other => other.did !== author.did)
: [...selected, author];
save($state.snapshot(selected));
}
</script>
{#each options as author (author.did)}
<MenuCheckItem
checked={isSelected(author)}
label={author.alias ?? truncateId(publicKeyFromDid(author.did))}
title={author.did}
onclick={() => toggle(author)}>
{#snippet leading()}
<UserAvatar nodeId={author.did} styleWidth="1rem" />
{/snippet}
</MenuCheckItem>
{/each}
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Author } from "@bindings/cob/Author";
import AssigneesSubmenu from "@app/components/AssigneesSubmenu.svelte";
import ContextSubmenu from "@app/components/ContextSubmenu.svelte";
import LabelsSubmenu from "@app/components/LabelsSubmenu.svelte";
type MetadataAction =
| { type: "label"; labels: string[] }
| { type: "assign"; assignees: Author[] };
interface Props {
labels: string[];
assignees: Author[];
candidates: Author[];
labelSuggestions: string[];
edit: (action: MetadataAction) => void;
}
const { labels, assignees, candidates, labelSuggestions, edit }: Props =
$props();
</script>
<ContextSubmenu label="Labels" icon="label">
<LabelsSubmenu
{labels}
suggestions={labelSuggestions}
save={labels => edit({ type: "label", labels })} />
</ContextSubmenu>
<ContextSubmenu label="Assign" icon="avatar-incognito">
<AssigneesSubmenu
{assignees}
{candidates}
save={assignees => edit({ type: "assign", assignees })} />
</ContextSubmenu>
+46 -17
View File
@@ -1,10 +1,29 @@
<script lang="ts" module>
import { getContext, setContext } from "svelte";
export interface ContextMenuState {
openSubmenu: symbol | undefined;
}
const contextKey = Symbol("contextMenu");
export function getContextMenu(): ContextMenuState {
return getContext<ContextMenuState>(contextKey);
}
</script>
<script lang="ts">
import type { Snippet } from "svelte";
import { autoUpdate, computePosition, flip, shift } from "@floating-ui/dom";
import { onMount } from "svelte";
import { menuFocusIndex } from "@app/lib/menuNavigation";
import {
focusMenuItem,
isOwnMenuItem,
menuItemSelector,
ownMenuItems,
} from "@app/lib/menuNavigation";
import { portal } from "@app/lib/portal";
import { getScrollViewport } from "@app/components/ScrollArea.svelte";
@@ -40,26 +59,29 @@
return document.scrollingElement as HTMLElement;
}
function menuItems(): HTMLElement[] {
if (!menuEl) return [];
return Array.from(
menuEl.querySelectorAll<HTMLElement>('[role="menuitem"]'),
);
}
const menuState: ContextMenuState = $state({ openSubmenu: undefined });
setContext(contextKey, menuState);
function onMenuKeydown(ev: KeyboardEvent) {
const items = menuItems();
if (items.length === 0) return;
const current = items.findIndex(el => el === document.activeElement);
const next = menuFocusIndex(ev.key, current, items.length);
if (next === undefined) return;
ev.preventDefault();
items[next].focus();
focusMenuItem(ev, ownMenuItems(menuEl));
}
function onMenuClick(ev: MouseEvent) {
const el = ev.target as HTMLElement | null;
if (el?.closest('[role="menuitem"]')) onclose();
const item = el?.closest('[role="menuitem"]');
if (item && !item.hasAttribute("aria-haspopup")) onclose();
}
function onMenuPointerOver(ev: PointerEvent) {
const item = (ev.target as HTMLElement | null)?.closest(menuItemSelector);
if (
item &&
menuEl &&
!item.hasAttribute("aria-haspopup") &&
isOwnMenuItem(menuEl, item)
) {
menuState.openSubmenu = undefined;
}
}
let menuEl: HTMLDivElement | undefined = $state();
@@ -169,10 +191,16 @@
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.menu :global(.menu-item:hover) {
.menu :global(.menu-item:hover),
.menu :global(.menu-item:focus-visible),
.menu :global(.menu-item[aria-expanded="true"]) {
background-color: var(--color-surface-subtle);
}
.menu :global(.menu-item:hover svg) {
.menu :global(.menu-item:focus-visible) {
outline: none;
}
.menu :global(.menu-item:hover svg),
.menu :global(.menu-item[aria-expanded="true"] svg) {
color: var(--color-text-primary);
}
.menu :global(.menu-separator) {
@@ -190,6 +218,7 @@
tabindex="-1"
onclick={onMenuClick}
onkeydown={onMenuKeydown}
onpointerover={onMenuPointerOver}
oncontextmenu={e => e.preventDefault()}>
{@render children()}
</div>
@@ -0,0 +1,123 @@
<script lang="ts">
import type { ComponentProps, Snippet } from "svelte";
import { autoUpdate, computePosition, flip, shift } from "@floating-ui/dom";
import { tick } from "svelte";
import { focusMenuItem, ownMenuItems } from "@app/lib/menuNavigation";
import { getContextMenu } from "@app/components/ContextMenu.svelte";
import Icon from "@app/components/Icon.svelte";
interface Props {
label: string;
icon: ComponentProps<typeof Icon>["name"];
children: Snippet;
}
const { label, icon, children }: Props = $props();
const menu = getContextMenu();
const id = Symbol();
const open = $derived(menu.openSubmenu === id);
let triggerEl: HTMLButtonElement | undefined = $state();
let panelEl: HTMLDivElement | undefined = $state();
async function show(focusFirst: boolean) {
menu.openSubmenu = id;
if (!focusFirst) return;
await tick();
ownMenuItems(panelEl)[0]?.focus();
}
function hide() {
menu.openSubmenu = undefined;
triggerEl?.focus();
}
function onTriggerKeydown(ev: KeyboardEvent) {
if (ev.key === "ArrowRight" || ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
ev.stopPropagation();
void show(true);
}
}
function onPanelKeydown(ev: KeyboardEvent) {
ev.stopPropagation();
const typing = ev.target instanceof HTMLInputElement;
if (ev.key === "Escape" || (ev.key === "ArrowLeft" && !typing)) {
ev.preventDefault();
hide();
return;
}
if (typing && (ev.key === "Home" || ev.key === "End")) return;
focusMenuItem(ev, ownMenuItems(panelEl));
}
$effect(() => {
if (!open || !triggerEl || !panelEl) return;
const trigger = triggerEl;
const panel = panelEl;
return autoUpdate(trigger, panel, () => {
void computePosition(trigger, panel, {
strategy: "fixed",
placement: "right-start",
middleware: [flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
panel.style.left = `${x + 4}px`;
panel.style.top = `${y - 4}px`;
panel.style.visibility = "visible";
});
});
});
</script>
<style>
.label {
flex: 1;
}
.submenu {
position: fixed;
top: 0;
left: 0;
visibility: hidden;
z-index: 101;
min-width: 12rem;
max-width: 20rem;
max-height: 20rem;
overflow-y: auto;
padding: 0.25rem;
background-color: var(--color-surface-canvas);
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
box-shadow: var(--elevation-low);
}
</style>
<button
bind:this={triggerEl}
type="button"
class="menu-item"
role="menuitem"
aria-haspopup="menu"
aria-expanded={open}
onpointerenter={() => void show(false)}
onclick={() => void show(false)}
onkeydown={onTriggerKeydown}>
<Icon name={icon} />
<span class="label">{label}</span>
<Icon name="chevron-right" />
</button>
{#if open}
<div
bind:this={panelEl}
class="submenu"
role="menu"
tabindex="-1"
onkeydown={onPanelKeydown}>
{@render children()}
</div>
{/if}
@@ -0,0 +1,162 @@
<script lang="ts">
import type { Action } from "@bindings/cob/issue/Action";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Config } from "@bindings/config/Config";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import { nodeRunning } from "@app/lib/events";
import { invoke, writeToClipboard } from "@app/lib/invoke";
import { entityUrl } from "@app/lib/mentions";
import { show } from "@app/lib/modal";
import * as roles from "@app/lib/roles";
import { serialEdits } from "@app/lib/serialEdits";
import { publicKeyFromDid } from "@app/lib/utils";
import { announce } from "@app/components/AnnounceSwitch.svelte";
import CobMetadataSubmenus from "@app/components/CobMetadataSubmenus.svelte";
import ContextMenu from "@app/components/ContextMenu.svelte";
import Icon from "@app/components/Icon.svelte";
import ConfirmDeleteCob from "@app/modals/ConfirmDeleteCob.svelte";
interface Props {
x: number;
y: number;
target?: HTMLElement;
issue: Issue;
repo: RepoInfo;
config: Config;
labelSuggestions: string[];
onchange: () => Promise<void>;
onclose: () => void;
}
const {
x,
y,
target,
issue,
repo,
config,
labelSuggestions,
onchange,
onclose,
}: Props = $props();
const delegates = $derived(repo.delegates.map(delegate => delegate.did));
const isDelegate = $derived(!!roles.isDelegate(config.publicKey, delegates));
const canEdit = $derived(
!!roles.isDelegateOrAuthor(config.publicKey, delegates, issue.author.did),
);
const isOwn = $derived(
publicKeyFromDid(issue.author.did) === config.publicKey,
);
const url = $derived(
entityUrl(
{ type: "cob", kind: "issue", rid: repo.rid, oid: issue.id },
config,
),
);
const enqueue = serialEdits("Editing issue failed", () => onchange());
function edit(action: Action) {
const args = {
rid: repo.rid,
cobId: issue.id,
action,
opts: { announce: $nodeRunning && $announce },
};
enqueue(() => invoke("edit_issue", args));
}
function deleteIssue() {
const args = {
rid: repo.rid,
cobId: issue.id,
opts: { announce: $nodeRunning && $announce },
};
const refresh = onchange;
show({
component: ConfirmDeleteCob,
props: {
noun: "issue",
title: issue.title,
icon: "issue",
confirm: async () => {
await invoke("delete_issue", args);
await refresh();
},
},
});
}
</script>
<ContextMenu {x} {y} {target} {onclose}>
{#if canEdit}
{#if issue.state.status === "open"}
<button
class="menu-item"
role="menuitem"
onclick={() =>
edit({
type: "lifecycle",
state: { status: "closed", reason: "solved" },
})}>
<Icon name="issue-closed" />
Close as solved
</button>
<button
class="menu-item"
role="menuitem"
onclick={() =>
edit({
type: "lifecycle",
state: { status: "closed", reason: "other" },
})}>
<Icon name="issue-closed" />
Close as other
</button>
{:else}
<button
class="menu-item"
role="menuitem"
onclick={() => edit({ type: "lifecycle", state: { status: "open" } })}>
<Icon name="issue" />
Reopen
</button>
{/if}
<div class="menu-separator"></div>
{/if}
{#if isDelegate}
<CobMetadataSubmenus
labels={issue.labels}
assignees={issue.assignees}
candidates={repo.delegates}
{labelSuggestions}
{edit} />
<div class="menu-separator"></div>
{/if}
<button
class="menu-item"
role="menuitem"
onclick={() => writeToClipboard(issue.id)}>
<Icon name="copy" />
Copy issue ID
</button>
{#if url}
<button
class="menu-item"
role="menuitem"
onclick={() => writeToClipboard(url)}>
<Icon name="link" />
Copy link
</button>
{/if}
{#if isOwn}
<div class="menu-separator"></div>
<button class="menu-item" role="menuitem" onclick={deleteIssue}>
<Icon name="trash" />
Delete…
</button>
{/if}
</ContextMenu>
+8 -2
View File
@@ -18,13 +18,16 @@
import NodeId from "@app/components/NodeId.svelte";
interface Props {
contextActive?: boolean;
focussed?: boolean;
oncontextmenu?: (event: MouseEvent) => void;
issue: Issue;
rid: string;
status: IssueStatus;
}
const { focussed, issue, rid, status }: Props = $props();
const { contextActive, focussed, oncontextmenu, issue, rid, status }: Props =
$props();
</script>
<style>
@@ -40,7 +43,8 @@
word-break: break-word;
width: 100%;
}
.issue-teaser:hover {
.issue-teaser:hover,
.issue-teaser.context-active {
background-color: var(--color-surface-subtle);
}
.status {
@@ -64,6 +68,8 @@
tabindex="0"
role="button"
class="issue-teaser"
class:context-active={contextActive}
{oncontextmenu}
style:align-items="flex-start"
style:clip-path={focussed ? "none" : undefined}
style:padding={focussed ? "1rem" : "20px"}
@@ -0,0 +1,72 @@
<script lang="ts">
import { labelError } from "@app/lib/inputValidation";
import MenuCheckItem from "@app/components/MenuCheckItem.svelte";
import TextInput from "@app/components/TextInput.svelte";
interface Props {
labels: string[];
suggestions: string[];
save: (labels: string[]) => void;
}
const { labels, suggestions, save }: Props = $props();
let selected: string[] = $derived([...labels]);
let added: string[] = $state([]);
let inputValue = $state("");
const options = $derived(
[...new Set([...suggestions, ...labels, ...added])].sort((a, b) =>
a.localeCompare(b),
),
);
const sanitizedValue = $derived(inputValue.trim());
const valid = $derived(labelError(inputValue, selected) === undefined);
function toggle(label: string) {
selected = selected.includes(label)
? selected.filter(l => l !== label)
: [...selected, label];
save($state.snapshot(selected));
}
function addLabel() {
if (!valid || !sanitizedValue) return;
added = [...added, sanitizedValue];
selected = [...selected, sanitizedValue];
inputValue = "";
save($state.snapshot(selected));
}
</script>
<style>
.input {
padding: 0.25rem;
}
.empty {
padding: 0.25rem 0.75rem 0.5rem;
color: var(--color-text-tertiary);
font: var(--txt-body-s-regular);
}
</style>
{#each options as label (label)}
<MenuCheckItem
checked={selected.includes(label)}
{label}
title={label}
onclick={() => toggle(label)} />
{:else}
<div class="empty">No labels yet</div>
{/each}
{#if options.length > 0}
<div class="menu-separator"></div>
{/if}
<div class="input">
<TextInput
placeholder="New label"
{valid}
bind:value={inputValue}
onSubmit={addLabel} />
</div>
@@ -0,0 +1,43 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Icon from "@app/components/Icon.svelte";
interface Props {
checked: boolean;
label: string;
title: string;
onclick: () => void;
leading?: Snippet;
}
const { checked, label, title, onclick, leading }: Props = $props();
</script>
<style>
.check {
display: inline-flex;
width: 1rem;
flex-shrink: 0;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
}
</style>
<button
type="button"
class="menu-item"
role="menuitemcheckbox"
aria-checked={checked}
{title}
{onclick}>
<span class="check">
{#if checked}
<Icon name="checkmark" />
{/if}
</span>
{@render leading?.()}
<span class="name">{label}</span>
</button>
@@ -0,0 +1,177 @@
<script lang="ts">
import type { Action } from "@bindings/cob/patch/Action";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { Config } from "@bindings/config/Config";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import { nodeRunning } from "@app/lib/events";
import { invoke, writeToClipboard } from "@app/lib/invoke";
import { entityUrl } from "@app/lib/mentions";
import { show } from "@app/lib/modal";
import * as roles from "@app/lib/roles";
import { serialEdits } from "@app/lib/serialEdits";
import { formatOid, publicKeyFromDid } from "@app/lib/utils";
import { announce } from "@app/components/AnnounceSwitch.svelte";
import CobMetadataSubmenus from "@app/components/CobMetadataSubmenus.svelte";
import ContextMenu from "@app/components/ContextMenu.svelte";
import Icon from "@app/components/Icon.svelte";
import ConfirmDeleteCob from "@app/modals/ConfirmDeleteCob.svelte";
interface Props {
x: number;
y: number;
target?: HTMLElement;
patch: Patch;
repo: RepoInfo;
config: Config;
labelSuggestions: string[];
onchange: () => Promise<void>;
onclose: () => void;
}
const {
x,
y,
target,
patch,
repo,
config,
labelSuggestions,
onchange,
onclose,
}: Props = $props();
const delegates = $derived(repo.delegates.map(delegate => delegate.did));
const isDelegate = $derived(!!roles.isDelegate(config.publicKey, delegates));
const canEdit = $derived(
!!roles.isDelegateOrAuthor(config.publicKey, delegates, patch.author.did),
);
const isOwn = $derived(
publicKeyFromDid(patch.author.did) === config.publicKey,
);
const status = $derived(patch.state.status);
const url = $derived(
entityUrl(
{ type: "cob", kind: "patch", rid: repo.rid, oid: patch.id },
config,
),
);
const enqueue = serialEdits("Editing patch failed", () => onchange());
function edit(action: Action) {
const args = {
rid: repo.rid,
cobId: patch.id,
action,
opts: { announce: $nodeRunning && $announce },
};
enqueue(() => invoke("edit_patch", args));
}
function setState(state: "draft" | "open" | "archived") {
edit({ type: "lifecycle", state: { status: state } });
}
function deletePatch() {
const args = {
rid: repo.rid,
cobId: patch.id,
opts: { announce: $nodeRunning && $announce },
};
const refresh = onchange;
show({
component: ConfirmDeleteCob,
props: {
noun: "patch",
title: patch.title,
icon: "patch",
confirm: async () => {
await invoke("delete_patch", args);
await refresh();
},
},
});
}
</script>
<ContextMenu {x} {y} {target} {onclose}>
{#if canEdit && status !== "merged"}
{#if status === "draft"}
<button
class="menu-item"
role="menuitem"
onclick={() => setState("open")}>
<Icon name="patch" />
Mark as ready for review
</button>
{:else if status === "open"}
<button
class="menu-item"
role="menuitem"
onclick={() => setState("draft")}>
<Icon name="patch-draft" />
Convert to draft
</button>
{/if}
{#if status === "archived"}
<button
class="menu-item"
role="menuitem"
onclick={() => setState("open")}>
<Icon name="patch" />
Reopen
</button>
{:else}
<button
class="menu-item"
role="menuitem"
onclick={() => setState("archived")}>
<Icon name="patch-archived" />
Archive
</button>
{/if}
<div class="menu-separator"></div>
{/if}
{#if isDelegate}
<CobMetadataSubmenus
labels={patch.labels}
assignees={patch.assignees}
candidates={repo.delegates}
{labelSuggestions}
{edit} />
<div class="menu-separator"></div>
{/if}
<button
class="menu-item"
role="menuitem"
onclick={() =>
writeToClipboard(`rad patch checkout ${formatOid(patch.id)}`)}>
<Icon name="checkout" />
Copy checkout command
</button>
<button
class="menu-item"
role="menuitem"
onclick={() => writeToClipboard(patch.id)}>
<Icon name="copy" />
Copy patch ID
</button>
{#if url}
<button
class="menu-item"
role="menuitem"
onclick={() => writeToClipboard(url)}>
<Icon name="link" />
Copy link
</button>
{/if}
{#if isOwn}
<div class="menu-separator"></div>
<button class="menu-item" role="menuitem" onclick={deletePatch}>
<Icon name="trash" />
Delete…
</button>
{/if}
</ContextMenu>
+8 -2
View File
@@ -23,13 +23,16 @@
import ReviewSummary from "@app/components/ReviewSummary.svelte";
interface Props {
contextActive?: boolean;
focussed?: boolean;
oncontextmenu?: (event: MouseEvent) => void;
patch: Patch;
rid: string;
status: PatchStatus | undefined;
}
const { focussed, patch, rid, status }: Props = $props();
const { contextActive, focussed, oncontextmenu, patch, rid, status }: Props =
$props();
// Drafts belong to an identity, so the marker has to know who is signed in.
// Until the config arrives the marker stays off rather than showing someone
@@ -70,7 +73,8 @@
word-break: break-word;
width: 100%;
}
.patch-teaser:hover {
.patch-teaser:hover,
.patch-teaser.context-active {
background-color: var(--color-surface-subtle);
}
.status {
@@ -94,6 +98,8 @@
tabindex="0"
role="button"
class="patch-teaser"
class:context-active={contextActive}
{oncontextmenu}
style:align-items="flex-start"
style:clip-path={focussed ? "none" : undefined}
style:padding={focussed ? "1rem" : "1.25rem"}