mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-25 06:40:30 +08:00
Add right-click menu to issue and patch lists
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
id: string;
|
||||
target: HTMLElement;
|
||||
}
|
||||
|
||||
export function createListContextMenu<
|
||||
T extends { id: string; labels: string[] },
|
||||
>(items: () => T[]) {
|
||||
let position: Position | undefined = $state();
|
||||
|
||||
const current = $derived.by(() => {
|
||||
if (!position) return undefined;
|
||||
const { id } = position;
|
||||
const item = items().find(item => item.id === id);
|
||||
return item && { ...position, item };
|
||||
});
|
||||
|
||||
const labelSuggestions = $derived(
|
||||
items()
|
||||
.flatMap(item => item.labels)
|
||||
.filter((label, index, all) => all.indexOf(label) === index),
|
||||
);
|
||||
|
||||
// The item can drop out of the list after a refresh; forget it so the menu
|
||||
// doesn't pop back up if it ever reappears.
|
||||
$effect(() => {
|
||||
if (position && !current) position = undefined;
|
||||
});
|
||||
|
||||
return {
|
||||
get current() {
|
||||
return current;
|
||||
},
|
||||
get labelSuggestions() {
|
||||
return labelSuggestions;
|
||||
},
|
||||
isActive(id: string): boolean {
|
||||
return current?.item.id === id;
|
||||
},
|
||||
open(event: MouseEvent, id: string) {
|
||||
event.preventDefault();
|
||||
position = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
id,
|
||||
target: event.currentTarget as HTMLElement,
|
||||
};
|
||||
},
|
||||
close() {
|
||||
position = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,3 +17,26 @@ export function menuFocusIndex(
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const menuItemSelector = '[role="menuitem"], [role="menuitemcheckbox"]';
|
||||
|
||||
// Whether `item` belongs to `menu` itself rather than to a nested submenu.
|
||||
export function isOwnMenuItem(menu: HTMLElement, item: Element): boolean {
|
||||
return item.parentElement?.closest('[role="menu"]') === menu;
|
||||
}
|
||||
|
||||
export function ownMenuItems(menu: HTMLElement | undefined): HTMLElement[] {
|
||||
if (!menu) return [];
|
||||
return Array.from(
|
||||
menu.querySelectorAll<HTMLElement>(menuItemSelector),
|
||||
).filter(item => isOwnMenuItem(menu, item));
|
||||
}
|
||||
|
||||
export function focusMenuItem(ev: KeyboardEvent, items: HTMLElement[]) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Runs edits one at a time in submission order and refreshes once the queue
|
||||
// drains, so quick successive toggles neither race nor refresh per edit.
|
||||
export function serialEdits(
|
||||
errorMessage: string,
|
||||
refresh: () => Promise<void>,
|
||||
): (run: () => Promise<unknown>) => void {
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
let pending = 0;
|
||||
|
||||
return run => {
|
||||
pending += 1;
|
||||
queue = queue.then(async () => {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
console.error(errorMessage, error);
|
||||
} finally {
|
||||
pending -= 1;
|
||||
if (pending === 0) await refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import ConfirmModal from "@app/modals/ConfirmModal.svelte";
|
||||
|
||||
interface Props {
|
||||
noun: "issue" | "patch";
|
||||
title: string;
|
||||
icon: ComponentProps<typeof Icon>["name"];
|
||||
confirm: () => Promise<void>;
|
||||
}
|
||||
|
||||
const { noun, title, icon, confirm }: Props = $props();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.cob {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font: var(--txt-body-m-medium);
|
||||
color: var(--color-text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
.cob-icon {
|
||||
display: inline-flex;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<ConfirmModal
|
||||
title={`Delete ${noun}`}
|
||||
width="26rem"
|
||||
icon="trash"
|
||||
label="Delete"
|
||||
busyLabel="Deleting…"
|
||||
describeError={() => `Could not delete the ${noun}.`}
|
||||
{confirm}>
|
||||
{#snippet body()}
|
||||
<div class="cob txt-overflow">
|
||||
<span class="cob-icon"><Icon name={icon} /></span>
|
||||
{title}
|
||||
</div>
|
||||
<span>
|
||||
Only your copy is removed. You won't be able to restore it here, and peers
|
||||
who have already replicated the {noun} keep theirs.
|
||||
</span>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
@@ -16,16 +16,19 @@
|
||||
resetIssueCounts,
|
||||
updateIssueCounts,
|
||||
} from "@app/lib/issueCounts.svelte";
|
||||
import { createListContextMenu } from "@app/lib/listContextMenu.svelte";
|
||||
import { show } from "@app/lib/modal";
|
||||
import { overflowFade } from "@app/lib/overflowFade";
|
||||
import { createPaginatedList } from "@app/lib/paginatedList.svelte";
|
||||
import * as router from "@app/lib/router";
|
||||
import type { SidebarData } from "@app/lib/router/definitions";
|
||||
import { modifierKey } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import CobCacheWarning from "@app/components/CobCacheWarning.svelte";
|
||||
import FuzzySearch from "@app/components/FuzzySearch.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import IssueContextMenu from "@app/components/IssueContextMenu.svelte";
|
||||
import IssueTeaser from "@app/components/IssueTeaser.svelte";
|
||||
import ScrollArea from "@app/components/ScrollArea.svelte";
|
||||
import Topbar from "@app/components/Topbar.svelte";
|
||||
@@ -38,9 +41,12 @@
|
||||
repo: RepoInfo;
|
||||
issues: PaginatedQuery<Issue[]>;
|
||||
status: IssueStatus;
|
||||
sidebarData: SidebarData;
|
||||
}
|
||||
|
||||
const { repo, issues, status }: Props = $props();
|
||||
/* eslint-disable prefer-const */
|
||||
let { repo, issues, status, sidebarData }: Props = $props();
|
||||
/* eslint-enable prefer-const */
|
||||
|
||||
function listKey(filter: IssueStatus): string {
|
||||
return `repo.issues:${repo.rid}:${filter}`;
|
||||
@@ -116,6 +122,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
const contextMenu = createListContextMenu(() => list.items);
|
||||
|
||||
async function refresh() {
|
||||
// Re-fetch the repo too: the tab counts come from its project payload.
|
||||
const [repoInfo] = await Promise.all([
|
||||
invoke<RepoInfo>("repo_by_id", { rid: repo.rid }),
|
||||
list.revalidate(Math.max(list.items.length, DEFAULT_TAKE)),
|
||||
]);
|
||||
repo = repoInfo;
|
||||
}
|
||||
|
||||
const searchResults = $derived(
|
||||
searchCobs(debouncedSearch.current, list.items),
|
||||
);
|
||||
@@ -322,6 +339,8 @@
|
||||
{#snippet row(issue)}
|
||||
<div class="row">
|
||||
<IssueTeaser
|
||||
contextActive={contextMenu.isActive(issue.id)}
|
||||
oncontextmenu={event => contextMenu.open(event, issue.id)}
|
||||
focussed={searchResults.length === 1 && searchInput !== ""}
|
||||
{issue}
|
||||
rid={repo.rid}
|
||||
@@ -332,4 +351,18 @@
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if contextMenu.current}
|
||||
{@const { x, y, target, item } = contextMenu.current}
|
||||
<IssueContextMenu
|
||||
{x}
|
||||
{y}
|
||||
{target}
|
||||
issue={item}
|
||||
{repo}
|
||||
config={sidebarData.config}
|
||||
labelSuggestions={contextMenu.labelSuggestions}
|
||||
onchange={refresh}
|
||||
onclose={contextMenu.close} />
|
||||
{/if}
|
||||
</Layout>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { searchCobs } from "@app/lib/cobSearch";
|
||||
import { debounced } from "@app/lib/debounced.svelte";
|
||||
import { channel, invoke } from "@app/lib/invoke";
|
||||
import { createListContextMenu } from "@app/lib/listContextMenu.svelte";
|
||||
import { overflowFade } from "@app/lib/overflowFade";
|
||||
import { createPaginatedList } from "@app/lib/paginatedList.svelte";
|
||||
import {
|
||||
@@ -19,12 +20,14 @@
|
||||
updatePatchCounts,
|
||||
} from "@app/lib/patchCounts.svelte";
|
||||
import * as router from "@app/lib/router";
|
||||
import type { SidebarData } from "@app/lib/router/definitions";
|
||||
import { modifierKey } from "@app/lib/utils";
|
||||
|
||||
import CobCacheWarning from "@app/components/CobCacheWarning.svelte";
|
||||
import FuzzySearch from "@app/components/FuzzySearch.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import NewPatchButton from "@app/components/NewPatchButton.svelte";
|
||||
import PatchContextMenu from "@app/components/PatchContextMenu.svelte";
|
||||
import PatchTeaser from "@app/components/PatchTeaser.svelte";
|
||||
import ScrollArea from "@app/components/ScrollArea.svelte";
|
||||
import Topbar from "@app/components/Topbar.svelte";
|
||||
@@ -36,9 +39,12 @@
|
||||
repo: RepoInfo;
|
||||
patches: PaginatedQuery<Patch[]>;
|
||||
status: PatchStatus | undefined;
|
||||
sidebarData: SidebarData;
|
||||
}
|
||||
|
||||
const { repo, patches, status }: Props = $props();
|
||||
/* eslint-disable prefer-const */
|
||||
let { repo, patches, status, sidebarData }: Props = $props();
|
||||
/* eslint-enable prefer-const */
|
||||
|
||||
function listKey(filter: PatchStatus | undefined): string {
|
||||
return `repo.patches:${repo.rid}:${filter ?? "all"}`;
|
||||
@@ -107,6 +113,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
const contextMenu = createListContextMenu(() => list.items);
|
||||
|
||||
async function refresh() {
|
||||
// Re-fetch the repo too: the tab counts come from its project payload.
|
||||
const [repoInfo] = await Promise.all([
|
||||
invoke<RepoInfo>("repo_by_id", { rid: repo.rid }),
|
||||
list.revalidate(Math.max(list.items.length, DEFAULT_TAKE)),
|
||||
]);
|
||||
repo = repoInfo;
|
||||
}
|
||||
|
||||
const searchResults = $derived(
|
||||
searchCobs(debouncedSearch.current, list.items),
|
||||
);
|
||||
@@ -346,6 +363,8 @@
|
||||
{#snippet row(patch)}
|
||||
<div class="row">
|
||||
<PatchTeaser
|
||||
contextActive={contextMenu.isActive(patch.id)}
|
||||
oncontextmenu={event => contextMenu.open(event, patch.id)}
|
||||
focussed={searchResults.length === 1 && searchInput !== ""}
|
||||
{patch}
|
||||
rid={repo.rid}
|
||||
@@ -356,4 +375,18 @@
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if contextMenu.current}
|
||||
{@const { x, y, target, item } = contextMenu.current}
|
||||
<PatchContextMenu
|
||||
{x}
|
||||
{y}
|
||||
{target}
|
||||
patch={item}
|
||||
{repo}
|
||||
config={sidebarData.config}
|
||||
labelSuggestions={contextMenu.labelSuggestions}
|
||||
onchange={refresh}
|
||||
onclose={contextMenu.close} />
|
||||
{/if}
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user