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"}
+55
View File
@@ -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;
},
};
}
+23
View File
@@ -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();
}
+23
View File
@@ -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>
+34 -1
View File
@@ -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>
+34 -1
View File
@@ -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>