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}