From 41e5b96b09b0fccb9ed6e1663d07efd252a5baf3 Mon Sep 17 00:00:00 2001 From: Brandon Oxendine Date: Wed, 14 Oct 2026 18:26:15 +0200 Subject: [PATCH] Add right-click menu to issue and patch lists --- .../src/components/AssigneesSubmenu.svelte | 48 +++++ .../src/components/CobMetadataSubmenus.svelte | 35 ++++ desktop/src/components/ContextMenu.svelte | 63 +++++-- desktop/src/components/ContextSubmenu.svelte | 123 ++++++++++++ .../src/components/IssueContextMenu.svelte | 162 ++++++++++++++++ desktop/src/components/IssueTeaser.svelte | 10 +- desktop/src/components/LabelsSubmenu.svelte | 72 +++++++ desktop/src/components/MenuCheckItem.svelte | 43 +++++ .../src/components/PatchContextMenu.svelte | 177 ++++++++++++++++++ desktop/src/components/PatchTeaser.svelte | 10 +- desktop/src/lib/listContextMenu.svelte.ts | 55 ++++++ desktop/src/lib/menuNavigation.ts | 23 +++ desktop/src/lib/serialEdits.ts | 23 +++ desktop/src/modals/ConfirmDeleteCob.svelte | 51 +++++ desktop/src/views/repo/Issues.svelte | 35 +++- desktop/src/views/repo/Patches.svelte | 35 +++- 16 files changed, 942 insertions(+), 23 deletions(-) create mode 100644 desktop/src/components/AssigneesSubmenu.svelte create mode 100644 desktop/src/components/CobMetadataSubmenus.svelte create mode 100644 desktop/src/components/ContextSubmenu.svelte create mode 100644 desktop/src/components/IssueContextMenu.svelte create mode 100644 desktop/src/components/LabelsSubmenu.svelte create mode 100644 desktop/src/components/MenuCheckItem.svelte create mode 100644 desktop/src/components/PatchContextMenu.svelte create mode 100644 desktop/src/lib/listContextMenu.svelte.ts create mode 100644 desktop/src/lib/serialEdits.ts create mode 100644 desktop/src/modals/ConfirmDeleteCob.svelte diff --git a/desktop/src/components/AssigneesSubmenu.svelte b/desktop/src/components/AssigneesSubmenu.svelte new file mode 100644 index 000000000..ba07503f5 --- /dev/null +++ b/desktop/src/components/AssigneesSubmenu.svelte @@ -0,0 +1,48 @@ + + +{#each options as author (author.did)} + toggle(author)}> + {#snippet leading()} + + {/snippet} + +{/each} diff --git a/desktop/src/components/CobMetadataSubmenus.svelte b/desktop/src/components/CobMetadataSubmenus.svelte new file mode 100644 index 000000000..cdb27709c --- /dev/null +++ b/desktop/src/components/CobMetadataSubmenus.svelte @@ -0,0 +1,35 @@ + + + + edit({ type: "label", labels })} /> + + + edit({ type: "assign", assignees })} /> + diff --git a/desktop/src/components/ContextMenu.svelte b/desktop/src/components/ContextMenu.svelte index 6446f61b2..aaf42afac 100644 --- a/desktop/src/components/ContextMenu.svelte +++ b/desktop/src/components/ContextMenu.svelte @@ -1,10 +1,29 @@ + + + + + + + +{#if open} + +{/if} diff --git a/desktop/src/components/IssueContextMenu.svelte b/desktop/src/components/IssueContextMenu.svelte new file mode 100644 index 000000000..84eafe716 --- /dev/null +++ b/desktop/src/components/IssueContextMenu.svelte @@ -0,0 +1,162 @@ + + + + {#if canEdit} + {#if issue.state.status === "open"} + + + {:else} + + {/if} + + {/if} + {#if isDelegate} + + + {/if} + + {#if url} + + {/if} + {#if isOwn} + + + {/if} + diff --git a/desktop/src/components/IssueTeaser.svelte b/desktop/src/components/IssueTeaser.svelte index 7b3d3d883..9d9e5e42c 100644 --- a/desktop/src/components/IssueTeaser.svelte +++ b/desktop/src/components/IssueTeaser.svelte @@ -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(); + +{#each options as label (label)} + toggle(label)} /> +{:else} +
No labels yet
+{/each} +{#if options.length > 0} + +{/if} +
+ +
diff --git a/desktop/src/components/MenuCheckItem.svelte b/desktop/src/components/MenuCheckItem.svelte new file mode 100644 index 000000000..4f72706e6 --- /dev/null +++ b/desktop/src/components/MenuCheckItem.svelte @@ -0,0 +1,43 @@ + + + + + diff --git a/desktop/src/components/PatchContextMenu.svelte b/desktop/src/components/PatchContextMenu.svelte new file mode 100644 index 000000000..91b287a55 --- /dev/null +++ b/desktop/src/components/PatchContextMenu.svelte @@ -0,0 +1,177 @@ + + + + {#if canEdit && status !== "merged"} + {#if status === "draft"} + + {:else if status === "open"} + + {/if} + {#if status === "archived"} + + {:else} + + {/if} + + {/if} + {#if isDelegate} + + + {/if} + + + {#if url} + + {/if} + {#if isOwn} + + + {/if} + diff --git a/desktop/src/components/PatchTeaser.svelte b/desktop/src/components/PatchTeaser.svelte index 80e9aa1eb..11afef4fc 100644 --- a/desktop/src/components/PatchTeaser.svelte +++ b/desktop/src/components/PatchTeaser.svelte @@ -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"} diff --git a/desktop/src/lib/listContextMenu.svelte.ts b/desktop/src/lib/listContextMenu.svelte.ts new file mode 100644 index 000000000..9a7b00f98 --- /dev/null +++ b/desktop/src/lib/listContextMenu.svelte.ts @@ -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; + }, + }; +} diff --git a/desktop/src/lib/menuNavigation.ts b/desktop/src/lib/menuNavigation.ts index c1f277389..3a39a8a3c 100644 --- a/desktop/src/lib/menuNavigation.ts +++ b/desktop/src/lib/menuNavigation.ts @@ -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(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(); +} diff --git a/desktop/src/lib/serialEdits.ts b/desktop/src/lib/serialEdits.ts new file mode 100644 index 000000000..abfeaa937 --- /dev/null +++ b/desktop/src/lib/serialEdits.ts @@ -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, +): (run: () => Promise) => void { + let queue: Promise = 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(); + } + }); + }; +} diff --git a/desktop/src/modals/ConfirmDeleteCob.svelte b/desktop/src/modals/ConfirmDeleteCob.svelte new file mode 100644 index 000000000..b2a2f7197 --- /dev/null +++ b/desktop/src/modals/ConfirmDeleteCob.svelte @@ -0,0 +1,51 @@ + + + + + `Could not delete the ${noun}.`} + {confirm}> + {#snippet body()} +
+ + {title} +
+ + Only your copy is removed. You won't be able to restore it here, and peers + who have already replicated the {noun} keep theirs. + + {/snippet} +
diff --git a/desktop/src/views/repo/Issues.svelte b/desktop/src/views/repo/Issues.svelte index e00ae6c43..15fa000f6 100644 --- a/desktop/src/views/repo/Issues.svelte +++ b/desktop/src/views/repo/Issues.svelte @@ -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; 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("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)}
contextMenu.open(event, issue.id)} focussed={searchResults.length === 1 && searchInput !== ""} {issue} rid={repo.rid} @@ -332,4 +351,18 @@ {/if}
+ + {#if contextMenu.current} + {@const { x, y, target, item } = contextMenu.current} + + {/if} diff --git a/desktop/src/views/repo/Patches.svelte b/desktop/src/views/repo/Patches.svelte index 7b9c5661a..7a9fd211e 100644 --- a/desktop/src/views/repo/Patches.svelte +++ b/desktop/src/views/repo/Patches.svelte @@ -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; 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("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)}
contextMenu.open(event, patch.id)} focussed={searchResults.length === 1 && searchInput !== ""} {patch} rid={repo.rid} @@ -356,4 +375,18 @@ {/if}
+ + {#if contextMenu.current} + {@const { x, y, target, item } = contextMenu.current} + + {/if}