+ {#if labs.boards}
+ {@render subItem(
+ repo.name,
+ router.routeToPath({
+ resource: "repo.board",
+ rid: repo.rid,
+ }),
+ "dashboard",
+ "Boards",
+ isBoard(repo.rid),
+ undefined,
+ )}
+ {/if}
{@render subItem(
repo.name,
router.routeToPath({
@@ -1225,7 +1243,7 @@
{#snippet subItem(
repoName: string,
href: string,
- icon: "branch" | "issue" | "patch" | "parcel",
+ icon: "branch" | "dashboard" | "issue" | "patch" | "parcel",
label: string,
active: boolean,
count: number | undefined,
diff --git a/desktop/src/lib/board.ts b/desktop/src/lib/board.ts
new file mode 100644
index 000000000..b22cc394c
--- /dev/null
+++ b/desktop/src/lib/board.ts
@@ -0,0 +1,685 @@
+import type { Author } from "@bindings/cob/Author";
+import type { Board } from "@bindings/cob/board/Board";
+import type { Card } from "@bindings/cob/board/Card";
+import type { Column } from "@bindings/cob/board/Column";
+import type { PatchLink } from "@bindings/cob/board/PatchLink";
+import type { Placement } from "@bindings/cob/board/Placement";
+import type { Issue } from "@bindings/cob/issue/Issue";
+import type { State } from "@bindings/cob/issue/State";
+import type { Patch } from "@bindings/cob/patch/Patch";
+
+import { publicKeyFromDid } from "@app/lib/utils";
+
+export type Priority = "urgent" | "high" | "medium" | "low" | "none";
+
+export type BoardView = "board" | "list";
+
+export const ISSUE_TYPE = "xyz.radicle.issue";
+export const PATCH_TYPE = "xyz.radicle.patch";
+
+// The evaluator's limits, in UTF-8 bytes.
+export const MAX_NAME_LENGTH = 100;
+export const MAX_COLUMNS = 50;
+export const MAX_COLUMN_ID_LENGTH = 64;
+export const MAX_POSITION_LENGTH = 64;
+
+export function utf8Length(text: string): number {
+ return new TextEncoder().encode(text).length;
+}
+
+export function validName(name: string): boolean {
+ const length = utf8Length(name.trim());
+ return length > 0 && length <= MAX_NAME_LENGTH;
+}
+
+// Mirrors `radicle_board::default_columns`.
+export const DEFAULT_COLUMNS: Column[] = [
+ { id: "backlog", name: "Backlog" },
+ { id: "todo", name: "Todo" },
+ { id: "in-progress", name: "In Progress" },
+ { id: "in-review", name: "In Review" },
+ { id: "done", name: "Done", closes: "solved" },
+ { id: "canceled", name: "Canceled", closes: "other" },
+];
+
+export const PRIORITIES: { id: Priority; label: string }[] = [
+ { id: "urgent", label: "Urgent" },
+ { id: "high", label: "High" },
+ { id: "medium", label: "Medium" },
+ { id: "low", label: "Low" },
+ { id: "none", label: "No priority" },
+];
+
+const PRIORITY_PREFIX = "priority:";
+
+export const PRIORITY_RANK: Record = {
+ urgent: 0,
+ high: 1,
+ medium: 2,
+ low: 3,
+ none: 4,
+};
+
+export function priorityOf(labels: string[]): Priority {
+ const label = labels.find(l => l.startsWith(PRIORITY_PREFIX));
+ const value = label?.slice(PRIORITY_PREFIX.length);
+ return PRIORITIES.find(p => p.id === value && p.id !== "none")?.id ?? "none";
+}
+
+export function labelsForPriority(
+ labels: string[],
+ priority: Priority,
+): string[] {
+ const rest = labels.filter(l => !l.startsWith(PRIORITY_PREFIX));
+ return priority === "none"
+ ? rest
+ : [...rest, `${PRIORITY_PREFIX}${priority}`];
+}
+
+export function displayLabels(labels: string[]): string[] {
+ return labels.filter(l => !l.startsWith(PRIORITY_PREFIX));
+}
+
+interface ItemBase {
+ key: string;
+ card: Card;
+ placement?: Placement;
+ column: string;
+ // The stored position when the card is placed in `column`, otherwise one
+ // derived from the card itself.
+ position: string;
+}
+
+export type BoardItem =
+ | (ItemBase & {
+ kind: "issue";
+ issue: Issue;
+ patches: Patch[];
+ // Closed, but shown in an open column because the board has no closing
+ // column.
+ showsClosed: boolean;
+ })
+ | (ItemBase & { kind: "patch"; patch: Patch })
+ | (ItemBase & { kind: "unresolved" });
+
+export type IssueItem = Extract;
+
+export type ForeignCard =
+ | { kind: "issue"; issue: Issue }
+ | { kind: "patch"; patch: Patch }
+ | { kind: "unresolved" };
+
+type Unpositioned = BoardItem extends infer T
+ ? T extends BoardItem
+ ? Omit
+ : never
+ : never;
+
+export function cardKey(card: Card): string {
+ return `${card.rid}/${card.typeName}/${card.oid}`;
+}
+
+export function cardFor(rid: string, typeName: string, oid: string): Card {
+ return { rid, typeName, oid };
+}
+
+export function itemTitle(item: Unpositioned): string {
+ if (item.kind === "issue") return item.issue.title;
+ if (item.kind === "patch") return item.patch.title;
+ return "Unresolved card";
+}
+
+export function itemLabels(item: Unpositioned): string[] {
+ if (item.kind === "issue") return item.issue.labels;
+ if (item.kind === "patch") return item.patch.labels;
+ return [];
+}
+
+export function itemTimestamp(item: Unpositioned): number {
+ if (item.kind === "issue") return item.issue.timestamp;
+ if (item.kind === "patch") return item.patch.timestamp;
+ return item.placement?.movedAt ?? 0;
+}
+
+export function itemAssignees(item: Unpositioned): Author[] {
+ if (item.kind === "issue") return item.issue.assignees;
+ if (item.kind === "patch") return item.patch.assignees;
+ return [];
+}
+
+export function itemComments(item: Unpositioned): number {
+ if (item.kind === "issue") return item.issue.commentCount;
+ if (item.kind === "patch") return item.patch.commentCount;
+ return 0;
+}
+
+function openColumn(columns: Column[]): string {
+ return (columns.find(c => !c.closes) ?? columns[0]).id;
+}
+
+function closingColumn(
+ columns: Column[],
+ reason: "solved" | "other",
+ placed: Column | undefined,
+): string | undefined {
+ if (placed?.closes === reason) return placed.id;
+ return (columns.find(c => c.closes === reason) ?? columns.find(c => c.closes))
+ ?.id;
+}
+
+function hasColumn(columns: Column[], id: string): boolean {
+ return columns.some(c => c.id === id);
+}
+
+// The issue's state decides where it shows: the stored placement counts only
+// while it agrees with the state.
+export function issueColumn(
+ issue: Issue,
+ patches: Patch[],
+ placement: Placement | undefined,
+ columns: Column[],
+): { column: string; showsClosed: boolean } {
+ const placed = columns.find(c => c.id === placement?.column);
+ if (issue.state.status === "closed") {
+ const column = closingColumn(columns, issue.state.reason, placed);
+ return column
+ ? { column, showsClosed: false }
+ : { column: openColumn(columns), showsClosed: true };
+ }
+ if (placed && !placed.closes) {
+ return { column: placed.id, showsClosed: false };
+ }
+ if (!placed) {
+ if (
+ patches.some(p => p.state.status === "open") &&
+ hasColumn(columns, "in-review")
+ ) {
+ return { column: "in-review", showsClosed: false };
+ }
+ if (
+ patches.some(p => p.state.status === "draft") &&
+ hasColumn(columns, "in-progress")
+ ) {
+ return { column: "in-progress", showsClosed: false };
+ }
+ }
+ return { column: openColumn(columns), showsClosed: false };
+}
+
+export function patchColumn(
+ patch: Patch,
+ placement: Placement | undefined,
+ columns: Column[],
+): string {
+ const placed = columns.find(c => c.id === placement?.column);
+ if (patch.state.status === "merged") {
+ return closingColumn(columns, "solved", placed) ?? openColumn(columns);
+ }
+ if (patch.state.status === "archived") {
+ return closingColumn(columns, "other", placed) ?? openColumn(columns);
+ }
+ return placed && !placed.closes ? placed.id : openColumn(columns);
+}
+
+function stateFor(column: Column): State {
+ return column.closes
+ ? { status: "closed", reason: column.closes }
+ : { status: "open" };
+}
+
+// The state only changes when the move crosses between open and closing
+// columns, or between closing columns with different reasons, as seen from
+// the column the card is shown in. Reordering a closed issue shown in an open
+// column never rewrites it.
+export function stateAfterMove(
+ item: IssueItem,
+ target: Column,
+ columns: Column[],
+): State {
+ const from = columns.find(c => c.id === item.column);
+ return from?.closes === target.closes ? item.issue.state : stateFor(target);
+}
+
+// Mirrors the evaluator's rule for closing columns, and on top of that
+// refuses drops after which the card wouldn't show in `target`.
+export function accepts(
+ item: BoardItem,
+ target: Column,
+ columns: Column[],
+ canClose: boolean,
+ rid: string,
+): boolean {
+ return refusal(item, target, columns, canClose, rid) === undefined;
+}
+
+// Why `item` can't be dropped in `target`, or `undefined` if it can.
+export function refusal(
+ item: BoardItem,
+ target: Column,
+ columns: Column[],
+ canClose: boolean,
+ rid: string,
+): string | undefined {
+ if (item.kind === "unresolved") return undefined;
+ if (item.kind === "patch" && item.patch.state.status === "merged") {
+ return "Merged patches can't be moved";
+ }
+ if (item.kind === "patch" && item.patch.state.status === "archived") {
+ return "Archived patches can't be moved";
+ }
+ if (target.closes && item.kind === "patch") {
+ return "Patches can't go in a column that closes issues";
+ }
+ const placed: Placement = {
+ card: item.card,
+ column: target.id,
+ position: "",
+ movedBy: { did: "" },
+ movedAt: 0,
+ };
+ if (item.kind === "patch") {
+ return patchColumn(item.patch, placed, columns) === target.id
+ ? undefined
+ : "This patch can't go in this column";
+ }
+ if (!canClose) {
+ // Mirrors the evaluator: others may only move a card between columns
+ // that do the same thing to issues, judged by where the card is stored.
+ const stored = columns.find(c => c.id === item.placement?.column)?.closes;
+ const shown = columns.find(c => c.id === item.column)?.closes;
+ if (target.closes && !stored) {
+ return "Only delegates and the author can close this issue";
+ }
+ if (target.closes && stored !== target.closes) {
+ return "Only delegates and the author can change how this issue was closed";
+ }
+ if (!target.closes && (stored || shown)) {
+ return "Only delegates and the author can reopen this issue";
+ }
+ }
+ const foreign = item.card.rid !== rid;
+ const state =
+ canClose && !foreign
+ ? stateAfterMove(item, target, columns)
+ : item.issue.state;
+ if (
+ issueColumn({ ...item.issue, state }, item.patches, placed, columns)
+ .column === target.id
+ ) {
+ return undefined;
+ }
+ return foreign
+ ? "Issues from other repositories can't be closed or reopened here"
+ : "This issue can't go in this column";
+}
+
+const DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
+const TIME_WIDTH = 8;
+const TIME_MAX = DIGITS.length ** TIME_WIDTH - 1;
+
+function base62(value: number, width: number): string {
+ let out = "";
+ let rest = Math.max(0, Math.floor(value));
+ for (let i = 0; i < width; i++) {
+ out = DIGITS[rest % DIGITS.length] + out;
+ rest = Math.floor(rest / DIGITS.length);
+ }
+ return out;
+}
+
+// A position for a card nobody has placed in this column: by priority, then
+// newest first, then card. Every peer derives the same one, so a card dropped
+// between two unplaced cards gets a stored position that keeps it there.
+function derivedPosition(item: Unpositioned): string {
+ const labels = itemLabels(item);
+ const timestamp = itemTimestamp(item);
+ const rank = DIGITS[1 + PRIORITY_RANK[priorityOf(labels)]];
+ return `U${rank}${base62(TIME_MAX - timestamp, TIME_WIDTH)}${item.card.oid.slice(0, 8)}1`;
+}
+
+export function groupItems(
+ rid: string,
+ board: Board | undefined,
+ issues: Issue[],
+ patches: Patch[],
+ links: PatchLink[],
+ foreign: Map = new Map(),
+): Record {
+ const columns = board?.columns ?? DEFAULT_COLUMNS;
+ const placements = new Map();
+ for (const placement of board?.cards ?? []) {
+ placements.set(cardKey(placement.card), placement);
+ }
+
+ const linkedPatches = new Set();
+ const patchesByIssue = new Map();
+ const patchById = new Map(patches.map(p => [p.id, p]));
+ const issueIds = new Set(issues.map(i => i.id));
+ for (const link of links) {
+ const patch = patchById.get(link.patch);
+ if (!patch) continue;
+ for (const issue of link.issues.filter(id => issueIds.has(id))) {
+ linkedPatches.add(patch.id);
+ patchesByIssue.set(issue, [...(patchesByIssue.get(issue) ?? []), patch]);
+ }
+ }
+
+ const items: Unpositioned[] = [];
+ for (const issue of issues) {
+ const card = cardFor(rid, ISSUE_TYPE, issue.id);
+ const key = cardKey(card);
+ const placement = placements.get(key);
+ const linked = patchesByIssue.get(issue.id) ?? [];
+ items.push({
+ kind: "issue",
+ key,
+ card,
+ placement,
+ issue,
+ patches: linked,
+ ...issueColumn(issue, linked, placement, columns),
+ });
+ }
+ for (const patch of patches) {
+ if (linkedPatches.has(patch.id)) continue;
+ const card = cardFor(rid, PATCH_TYPE, patch.id);
+ const key = cardKey(card);
+ const placement = placements.get(key);
+ items.push({
+ kind: "patch",
+ key,
+ card,
+ placement,
+ patch,
+ column: patchColumn(patch, placement, columns),
+ });
+ }
+
+ for (const placement of board?.cards ?? []) {
+ if (placement.card.rid === rid) continue;
+ const key = cardKey(placement.card);
+ const resolved = foreign.get(key) ?? { kind: "unresolved" };
+ const base = { key, card: placement.card, placement };
+ if (resolved.kind === "issue") {
+ items.push({
+ ...base,
+ kind: "issue",
+ issue: resolved.issue,
+ patches: [],
+ ...issueColumn(resolved.issue, [], placement, columns),
+ });
+ } else if (resolved.kind === "patch") {
+ items.push({
+ ...base,
+ kind: "patch",
+ patch: resolved.patch,
+ column: patchColumn(resolved.patch, placement, columns),
+ });
+ } else {
+ items.push({
+ ...base,
+ kind: "unresolved",
+ column: columns.some(c => c.id === placement.column)
+ ? placement.column
+ : openColumn(columns),
+ });
+ }
+ }
+
+ const groups: Record = Object.fromEntries(
+ columns.map(c => [c.id, []]),
+ );
+ for (const item of items) {
+ const position =
+ item.placement?.column === item.column
+ ? item.placement.position
+ : derivedPosition(item);
+ groups[item.column].push({ ...item, position } as BoardItem);
+ }
+ for (const list of Object.values(groups)) {
+ list.sort((a, b) =>
+ a.position < b.position
+ ? -1
+ : a.position > b.position
+ ? 1
+ : a.key < b.key
+ ? -1
+ : a.key > b.key
+ ? 1
+ : 0,
+ );
+ }
+ return groups;
+}
+
+export interface Move {
+ item: BoardItem;
+ column: string;
+ index: number;
+}
+
+export function applyMove(
+ groups: Record,
+ move: Move | undefined,
+): Record {
+ if (!move) return groups;
+ const result = Object.fromEntries(
+ Object.entries(groups).map(([id, list]) => [
+ id,
+ list.filter(i => i.key !== move.item.key),
+ ]),
+ );
+ const target = [...(result[move.column] ?? [])];
+ target.splice(Math.min(move.index, target.length), 0, move.item);
+ result[move.column] = target;
+ return result;
+}
+
+// Mirrors `radicle_board::position::is_valid`.
+function isValidPosition(position: string): boolean {
+ return (
+ position.length > 0 &&
+ [...position].every(c => DIGITS.includes(c)) &&
+ !position.endsWith("0")
+ );
+}
+
+// Mirrors `radicle_board::position::between`, including giving up at the
+// length limit.
+export function between(before?: string, after?: string): string | undefined {
+ if (before !== undefined && !isValidPosition(before)) return undefined;
+ if (after !== undefined && !isValidPosition(after)) return undefined;
+ if (before !== undefined && after !== undefined && before >= after) {
+ return undefined;
+ }
+
+ const low = before ?? "";
+ let high = after;
+ let key = "";
+ for (let i = 0; i < MAX_POSITION_LENGTH; i++) {
+ const lo = i < low.length ? DIGITS.indexOf(low[i]) : 0;
+ const hi =
+ high === undefined
+ ? DIGITS.length
+ : i < high.length
+ ? DIGITS.indexOf(high[i])
+ : 0;
+ if (hi > lo + 1) {
+ return key + DIGITS[Math.floor((lo + hi) / 2)];
+ }
+ key += DIGITS[lo];
+ if (hi === lo + 1) high = undefined;
+ }
+ return undefined;
+}
+
+export interface Bounds {
+ before?: string;
+ after?: string;
+}
+
+// Mirrors `radicle_board::position::neighbours`.
+export function neighbourPositions(list: BoardItem[], index: number): Bounds {
+ const before = list[index - 1]?.position;
+ const after = list[index]?.position;
+ if (before !== undefined && after !== undefined && before >= after) {
+ return {
+ before,
+ after: list.slice(index).find(i => i.position > before)?.position,
+ };
+ }
+ return { before, after };
+}
+
+// The position has no value when the keys around `index` are packed to the
+// length limit; the card then stays where it was rather than going elsewhere.
+export function dropPosition(
+ list: BoardItem[],
+ index: number,
+): { bounds: Bounds; position?: string } {
+ const bounds = neighbourPositions(list, index);
+ return { bounds, position: between(bounds.before, bounds.after) };
+}
+
+export function isMe(did: string, publicKey: string): boolean {
+ return publicKeyFromDid(did) === publicKey;
+}
+
+export interface QueuedChange {
+ rid: string;
+ board: string;
+ opts: { announce: boolean };
+ card: Card;
+ change:
+ { kind: "move"; column: string; position: string } | { kind: "remove" };
+ // The state the card's issue moves to with it, if it changes.
+ state?: State;
+}
+
+export function sameState(a: State, b: State): boolean {
+ return (
+ a.status === b.status &&
+ (a.status !== "closed" || (b.status === "closed" && a.reason === b.reason))
+ );
+}
+
+export function overlayBoard(
+ board: Board | undefined,
+ queued: QueuedChange[],
+ movedBy: string,
+ movedAt: number,
+): Board | undefined {
+ if (!board) return board;
+ const entries = queued.filter(q => q.board === board.id);
+ if (entries.length === 0) return board;
+ const keys = new Set(entries.map(q => cardKey(q.card)));
+ const cards = board.cards.filter(p => !keys.has(cardKey(p.card)));
+ for (const q of entries) {
+ if (q.change.kind !== "move") continue;
+ cards.push({
+ card: q.card,
+ column: q.change.column,
+ position: q.change.position,
+ movedBy: { did: movedBy },
+ movedAt,
+ });
+ }
+ return { ...board, cards };
+}
+
+// Later entries win, so pass changes being written before the ones still
+// queued.
+export function overlayIssues(
+ rid: string,
+ issues: Issue[],
+ queued: QueuedChange[],
+): Issue[] {
+ const states = new Map(
+ queued
+ .filter(q => q.state && q.card.rid === rid)
+ .map(q => [q.card.oid, q.state!]),
+ );
+ if (states.size === 0) return issues;
+ return issues.map(i => {
+ const state = states.get(i.id);
+ return state ? { ...i, state } : i;
+ });
+}
+
+// Where the card stands as far as new changes are concerned: where the change
+// being written puts it, otherwise where the committed board has it.
+export function baselinePlacement(
+ board: Board | undefined,
+ card: Card,
+ inFlight: QueuedChange | undefined,
+): { column: string; position: string } | undefined {
+ const key = cardKey(card);
+ if (
+ inFlight &&
+ cardKey(inFlight.card) === key &&
+ inFlight.board === board?.id
+ ) {
+ return inFlight.change.kind === "move" ? inFlight.change : undefined;
+ }
+ return board?.cards.find(p => cardKey(p.card) === key);
+}
+
+// `bounds` are the positions the card was dropped between; without a manual
+// ordering any spot in the column will do.
+export function isUnchanged(
+ entry: QueuedChange,
+ placed: { column: string; position: string } | undefined,
+ manual: boolean,
+ bounds: Bounds = {},
+): boolean {
+ const change = entry.change;
+ if (change.kind === "remove") return placed === undefined;
+ return (
+ placed !== undefined &&
+ placed.column === change.column &&
+ (!manual ||
+ ((bounds.before === undefined || bounds.before < placed.position) &&
+ (bounds.after === undefined || placed.position < bounds.after))) &&
+ entry.state === undefined
+ );
+}
+
+export interface Batch {
+ rid: string;
+ board: string;
+ opts: { announce: boolean };
+ entries: QueuedChange[];
+ moves: { card: Card; column: string; position: string }[];
+ removes: Card[];
+}
+
+export function batches(queued: QueuedChange[]): Batch[] {
+ const result: Batch[] = [];
+ for (const entry of queued) {
+ let batch = result.find(
+ b => b.rid === entry.rid && b.board === entry.board,
+ );
+ if (!batch) {
+ batch = {
+ rid: entry.rid,
+ board: entry.board,
+ opts: entry.opts,
+ entries: [],
+ moves: [],
+ removes: [],
+ };
+ result.push(batch);
+ }
+ batch.entries.push(entry);
+ if (entry.change.kind === "move") {
+ batch.moves.push({
+ card: entry.card,
+ column: entry.change.column,
+ position: entry.change.position,
+ });
+ } else {
+ batch.removes.push(entry.card);
+ }
+ }
+ return result;
+}
diff --git a/desktop/src/lib/boardActions.ts b/desktop/src/lib/boardActions.ts
new file mode 100644
index 000000000..f4c6b6d96
--- /dev/null
+++ b/desktop/src/lib/boardActions.ts
@@ -0,0 +1,44 @@
+import type { Board } from "@bindings/cob/board/Board";
+
+import { get } from "svelte/store";
+
+import { nodeRunning } from "@app/lib/events";
+import { invoke } from "@app/lib/invoke";
+import { show } from "@app/lib/modal";
+import * as router from "@app/lib/router";
+
+import { announce } from "@app/components/AnnounceSwitch.svelte";
+import CreateBoard from "@app/modals/CreateBoard.svelte";
+
+export const DEFAULT_BOARD_NAME = "Board";
+
+// A board is only ever created on request, and after confirmation. The
+// first board is simply "Board"; later ones are numbered from the count.
+export async function promptNewBoard(rid: string, repoName: string) {
+ const boards = await invoke("list_boards", { rid });
+ const opts = () => ({ announce: get(nodeRunning) && get(announce) });
+
+ show({
+ component: CreateBoard,
+ props: {
+ title: boards.length === 0 ? "Create board" : "New board",
+ repoName,
+ name:
+ boards.length === 0
+ ? DEFAULT_BOARD_NAME
+ : `${DEFAULT_BOARD_NAME} ${boards.length + 1}`,
+ confirm: async (name: string) => {
+ const created = await invoke("create_board", {
+ rid,
+ name,
+ opts: opts(),
+ });
+ await router.push({
+ resource: "repo.board",
+ rid,
+ board: created.id,
+ });
+ },
+ },
+ });
+}
diff --git a/desktop/src/lib/boardDrag.svelte.ts b/desktop/src/lib/boardDrag.svelte.ts
new file mode 100644
index 000000000..cf669dc4f
--- /dev/null
+++ b/desktop/src/lib/boardDrag.svelte.ts
@@ -0,0 +1,413 @@
+import type { Column } from "@bindings/cob/board/Column";
+
+import { tick } from "svelte";
+
+import type { BoardItem } from "@app/lib/board";
+
+const DRAG_THRESHOLD = 4;
+const LANDING_MS = 160;
+const NUDGE_MAX = 24;
+
+export interface DragHost {
+ // The element the cards are dropped into; its columns carry `data-column`
+ // and its cards `data-key`.
+ readonly container: HTMLElement | undefined;
+ // Columns side by side, else stacked groups.
+ readonly horizontal: boolean;
+ readonly draggable: boolean;
+ readonly columns: Column[];
+ readonly canReorderColumns: boolean;
+ canMove(item: BoardItem): boolean;
+ canDrop(item: BoardItem, column: string): boolean;
+ sameSpot(item: BoardItem, column: string, index: number): boolean;
+ drop(item: BoardItem, column: string, index: number): void;
+ reorderColumns(columns: Column[]): void;
+}
+
+interface Grab {
+ item: BoardItem;
+ offsetX: number;
+ offsetY: number;
+ width: number;
+ height: number;
+}
+
+export type Drag = Grab & {
+ over: string | undefined;
+ index: number | undefined;
+ // The column under the pointer when it won't take the card.
+ refused: string | undefined;
+};
+
+interface LockedGrab {
+ item: BoardItem;
+ x: number;
+ y: number;
+ left: number;
+ top: number;
+ width: number;
+}
+
+export type Nudge = LockedGrab & { dx: number; dy: number; releasing: boolean };
+
+interface ColumnGrab {
+ id: string;
+ x: number;
+ active: boolean;
+}
+
+function layoutRect(el: Element): { x: number; y: number; rect: DOMRect } {
+ const rect = el.getBoundingClientRect();
+ const matrix = new DOMMatrix(getComputedStyle(el).transform);
+ return { x: rect.left - matrix.m41, y: rect.top - matrix.m42, rect };
+}
+
+function rubber(distance: number): number {
+ return (
+ Math.sign(distance) *
+ NUDGE_MAX *
+ (1 - Math.exp(-Math.abs(distance) / (NUDGE_MAX * 3)))
+ );
+}
+
+// Cards are dragged with a floating copy; a card the viewer can't move
+// follows the pointer a little and springs back instead. Column titles drag
+// the column among the visible ones.
+export class BoardDrag {
+ #host: DragHost;
+ #pending: (Grab & { x: number; y: number }) | undefined;
+ #drag: Drag | undefined = $state();
+ #pointer = $state({ x: 0, y: 0 });
+ #landing: { x: number; y: number } | undefined = $state();
+ #suppressClick = false;
+ #scrollFrame: number | undefined;
+ #lockedGrab: LockedGrab | undefined;
+ #nudge: Nudge | undefined = $state();
+ #columnGrab: ColumnGrab | undefined = $state();
+ #columnPreview: Column[] | undefined = $state();
+
+ constructor(host: DragHost) {
+ this.#host = host;
+ }
+
+ get current(): Drag | undefined {
+ return this.#drag;
+ }
+
+ get pointer(): { x: number; y: number } {
+ return this.#pointer;
+ }
+
+ get landing(): { x: number; y: number } | undefined {
+ return this.#landing;
+ }
+
+ get nudge(): Nudge | undefined {
+ return this.#nudge;
+ }
+
+ get columnPreview(): Column[] | undefined {
+ return this.#columnPreview;
+ }
+
+ get liftedColumn(): string | undefined {
+ return this.#columnPreview && this.#columnGrab?.id;
+ }
+
+ // Whether the click that ends a gesture should be ignored.
+ get suppressClick(): boolean {
+ return this.#suppressClick;
+ }
+
+ #swallowNextClick() {
+ this.#suppressClick = true;
+ setTimeout(() => (this.#suppressClick = false));
+ }
+
+ onCardPointerDown(event: PointerEvent, item: BoardItem) {
+ if (event.button !== 0 || this.#landing || !this.#host.draggable) return;
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
+ if (!this.#host.canMove(item)) {
+ this.#lockedGrab = {
+ item,
+ x: event.clientX,
+ y: event.clientY,
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ };
+ return;
+ }
+ this.#pending = {
+ item,
+ x: event.clientX,
+ y: event.clientY,
+ offsetX: event.clientX - rect.left,
+ offsetY: event.clientY - rect.top,
+ width: rect.width,
+ height: rect.height,
+ };
+ }
+
+ onTitlePointerDown(event: PointerEvent, column: Column) {
+ if (!this.#host.canReorderColumns || event.button !== 0) return;
+ this.#columnGrab = { id: column.id, x: event.clientX, active: false };
+ }
+
+ onPointerMove(event: PointerEvent) {
+ if (this.#moveColumn(event)) return;
+ if (this.#lockedGrab) {
+ const dx = event.clientX - this.#lockedGrab.x;
+ const dy = event.clientY - this.#lockedGrab.y;
+ if (this.#nudge || Math.hypot(dx, dy) > DRAG_THRESHOLD) {
+ this.#nudge = {
+ ...this.#lockedGrab,
+ dx: rubber(dx),
+ dy: rubber(dy),
+ releasing: false,
+ };
+ }
+ return;
+ }
+ if (this.#landing) return;
+ this.#pointer = { x: event.clientX, y: event.clientY };
+ if (this.#drag) {
+ this.#updateOver();
+ if (this.#scrollFrame === undefined) {
+ this.#scrollFrame = requestAnimationFrame(() => this.#autoScroll());
+ }
+ } else if (
+ this.#pending &&
+ Math.hypot(
+ event.clientX - this.#pending.x,
+ event.clientY - this.#pending.y,
+ ) > DRAG_THRESHOLD
+ ) {
+ this.#drag = {
+ ...this.#pending,
+ over: undefined,
+ index: undefined,
+ refused: undefined,
+ };
+ this.#pending = undefined;
+ this.#updateOver();
+ }
+ }
+
+ onPointerUp() {
+ if (this.#endColumn(false)) return;
+ this.#releaseNudge();
+ this.#pending = undefined;
+ void this.#land(false);
+ }
+
+ onPointerCancel() {
+ this.#releaseNudge();
+ this.#pending = undefined;
+ if (!this.#landing) this.#drag = undefined;
+ }
+
+ // Whether an Escape press ended a gesture.
+ onEscape(event: KeyboardEvent): boolean {
+ if (this.#columnGrab) {
+ event.preventDefault();
+ this.#endColumn(true);
+ return true;
+ }
+ if (this.#nudge) {
+ this.#releaseNudge();
+ return true;
+ }
+ if (this.#drag || this.#pending) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.#pending = undefined;
+ void this.#land(true);
+ return true;
+ }
+ return false;
+ }
+
+ #releaseNudge() {
+ this.#lockedGrab = undefined;
+ if (!this.#nudge) return;
+ this.#swallowNextClick();
+ const key = this.#nudge.item.key;
+ this.#nudge = { ...this.#nudge, dx: 0, dy: 0, releasing: true };
+ setTimeout(() => {
+ if (this.#nudge?.item.key === key && this.#nudge.releasing) {
+ this.#nudge = undefined;
+ }
+ }, 260);
+ }
+
+ #columnAt(x: number, y: number): string | undefined {
+ const container = this.#host.container;
+ const bounds = container?.getBoundingClientRect();
+ if (!container || !bounds || y < bounds.top || y > bounds.bottom) {
+ return undefined;
+ }
+ let nearest: { column: string; distance: number } | undefined;
+ for (const el of container.querySelectorAll("[data-column]")) {
+ const rect = el.getBoundingClientRect();
+ const [pos, start, end] = this.#host.horizontal
+ ? [x, rect.left, rect.right]
+ : [y, rect.top, rect.bottom];
+ const distance = pos < start ? start - pos : pos > end ? pos - end : 0;
+ if (!nearest || distance < nearest.distance) {
+ nearest = { column: el.dataset.column!, distance };
+ }
+ }
+ return nearest?.column;
+ }
+
+ // The number of other cards in `column` whose middle is above the pointer,
+ // ignoring any flip animation still moving them.
+ #indexAt(column: string, y: number, key: string): number {
+ const el = this.#host.container?.querySelector(
+ `[data-column="${CSS.escape(column)}"]`,
+ );
+ if (!el) return 0;
+ let index = 0;
+ for (const card of el.querySelectorAll("[data-key]")) {
+ if (card.dataset.key === key) continue;
+ const { y: top, rect } = layoutRect(card);
+ if (top + rect.height / 2 < y) index++;
+ }
+ return index;
+ }
+
+ // Only reassigning on a change keeps the columns from re-rendering on every
+ // pointer move, which would restart the cards' flip animations mid-flight.
+ #updateOver() {
+ if (!this.#drag) return;
+ const column = this.#columnAt(this.#pointer.x, this.#pointer.y);
+ const allowed =
+ column !== undefined && this.#host.canDrop(this.#drag.item, column);
+ const over = allowed ? column : undefined;
+ const refused = allowed ? undefined : column;
+ const index =
+ over === undefined
+ ? undefined
+ : this.#indexAt(over, this.#pointer.y, this.#drag.item.key);
+ if (
+ over !== this.#drag.over ||
+ index !== this.#drag.index ||
+ refused !== this.#drag.refused
+ ) {
+ this.#drag = { ...this.#drag, over, index, refused };
+ }
+ }
+
+ #autoScroll() {
+ this.#scrollFrame = undefined;
+ const container = this.#host.container;
+ if (!this.#drag || this.#landing || !container || !this.#host.horizontal) {
+ return;
+ }
+ const rect = container.getBoundingClientRect();
+ const edge = 80;
+ const speed =
+ this.#pointer.x > rect.right - edge
+ ? 12
+ : this.#pointer.x < rect.left + edge
+ ? -12
+ : 0;
+ if (speed === 0) return;
+ container.scrollLeft += speed;
+ this.#updateOver();
+ this.#scrollFrame = requestAnimationFrame(() => this.#autoScroll());
+ }
+
+ // The column a card just left keeps its placeholder while it fades out, so
+ // the slot is looked up in the column the card lands in.
+ #slotPosition(): { x: number; y: number } | undefined {
+ if (!this.#drag) return undefined;
+ const column = this.#drag.over ?? this.#drag.item.column;
+ const el = this.#host.container?.querySelector(
+ `[data-column="${CSS.escape(column)}"] .placeholder`,
+ );
+ if (!el) return undefined;
+ const { x, y } = layoutRect(el);
+ return { x, y };
+ }
+
+ async #land(cancel: boolean) {
+ if (!this.#drag || this.#landing) return;
+ this.#swallowNextClick();
+
+ if (cancel) {
+ this.#drag = {
+ ...this.#drag,
+ over: undefined,
+ index: undefined,
+ refused: undefined,
+ };
+ await tick();
+ }
+ const slot = this.#slotPosition();
+ if (slot) {
+ this.#landing = slot;
+ await new Promise(resolve => setTimeout(resolve, LANDING_MS));
+ }
+
+ const { item, over, index } = this.#drag;
+ if (
+ !cancel &&
+ over !== undefined &&
+ index !== undefined &&
+ !this.#host.sameSpot(item, over, index)
+ ) {
+ this.#host.drop(item, over, index);
+ }
+ this.#drag = undefined;
+ this.#landing = undefined;
+ }
+
+ // Moves the dragged column among the visible ones as the pointer crosses
+ // their middles; hidden columns keep their place in the full order.
+ #moveColumn(event: PointerEvent): boolean {
+ const grab = this.#columnGrab;
+ if (!grab) return false;
+ const columns = this.#host.columns;
+ if (!grab.active) {
+ if (Math.abs(event.clientX - grab.x) < DRAG_THRESHOLD) return true;
+ grab.active = true;
+ this.#columnPreview = [...columns];
+ }
+ const others = [
+ ...(this.#host.container?.querySelectorAll(
+ "[data-column]",
+ ) ?? []),
+ ].filter(el => el.dataset.column !== grab.id);
+ let index = 0;
+ for (const el of others) {
+ const { x, rect } = layoutRect(el);
+ if (x + rect.width / 2 < event.clientX) index++;
+ }
+ const order = (this.#columnPreview ?? columns).filter(
+ c => c.id !== grab.id,
+ );
+ const dragged = columns.find(c => c.id === grab.id)!;
+ const anchor = others[index - 1]?.dataset.column;
+ const at = anchor ? order.findIndex(c => c.id === anchor) + 1 : 0;
+ const next = [...order.slice(0, at), dragged, ...order.slice(at)];
+ if (next.some((c, i) => c.id !== this.#columnPreview?.[i]?.id)) {
+ this.#columnPreview = next;
+ }
+ return true;
+ }
+
+ #endColumn(cancel: boolean): boolean {
+ if (!this.#columnGrab) return false;
+ const { active } = this.#columnGrab;
+ this.#columnGrab = undefined;
+ const next = this.#columnPreview;
+ this.#columnPreview = undefined;
+ if (active) {
+ this.#swallowNextClick();
+ if (!cancel && next) this.#host.reorderColumns(next);
+ }
+ return true;
+ }
+}
diff --git a/desktop/src/lib/boardQueue.svelte.ts b/desktop/src/lib/boardQueue.svelte.ts
new file mode 100644
index 000000000..0c886a80a
--- /dev/null
+++ b/desktop/src/lib/boardQueue.svelte.ts
@@ -0,0 +1,165 @@
+import type { Board } from "@bindings/cob/board/Board";
+import type { Card } from "@bindings/cob/board/Card";
+import type { State } from "@bindings/cob/issue/State";
+
+import { SvelteMap } from "svelte/reactivity";
+
+import type { Batch, Bounds, QueuedChange } from "@app/lib/board";
+import {
+ baselinePlacement,
+ batches,
+ cardKey,
+ isUnchanged,
+} from "@app/lib/board";
+
+export const FLUSH_DELAY_MS = 2500;
+
+export interface BoardWriter {
+ // Writes a batch as one operation and returns the cards whose changes the
+ // backend rejected.
+ update(batch: Batch, announce: boolean): Promise;
+ setState(
+ rid: string,
+ oid: string,
+ state: State,
+ announce: boolean,
+ ): Promise;
+ // The committed board or issues of `rid` changed underneath the queue.
+ reload(rid: string): Promise;
+ error(message: string): void;
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+// Changes wait until nothing has changed for a moment, so a burst becomes one
+// operation and one update of the signed refs. Queued changes are shown on
+// top of the committed board until written; entries being written keep their
+// issue state until the issue's own write lands.
+export class BoardQueue {
+ #queued = new SvelteMap();
+ #writing = new SvelteMap();
+ #writer: BoardWriter;
+ #delay: number;
+ #timer: ReturnType | undefined;
+ #flushing: Promise = Promise.resolve();
+
+ constructor(writer: BoardWriter, delay = FLUSH_DELAY_MS) {
+ this.#writer = writer;
+ this.#delay = delay;
+ }
+
+ get queued(): QueuedChange[] {
+ return [...this.#queued.values()];
+ }
+
+ get writing(): QueuedChange[] {
+ return [...this.#writing.values()];
+ }
+
+ inFlight(key: string): QueuedChange | undefined {
+ return this.#writing.get(key);
+ }
+
+ // A change that leaves the card where it stands, such as a card moved away
+ // and back, drops the queued change instead of adding one.
+ enqueue(
+ entry: QueuedChange,
+ committed: Board | undefined,
+ manual: boolean,
+ bounds?: Bounds,
+ ) {
+ const key = cardKey(entry.card);
+ const placed = baselinePlacement(
+ committed,
+ entry.card,
+ this.#writing.get(key),
+ );
+ if (isUnchanged(entry, placed, manual, bounds)) {
+ this.#queued.delete(key);
+ } else {
+ this.#queued.set(key, entry);
+ }
+ clearTimeout(this.#timer);
+ this.#timer = setTimeout(() => void this.flush(), this.#delay);
+ }
+
+ flush(announce = true): Promise {
+ clearTimeout(this.#timer);
+ this.#timer = undefined;
+ const write = () => this.#writeQueued(announce);
+ this.#flushing = this.#flushing.then(write, write);
+ return this.#flushing;
+ }
+
+ // Entries changed again while they were being written stay queued.
+ #settle(entries: QueuedChange[]) {
+ for (const entry of entries) {
+ const key = cardKey(entry.card);
+ if (this.#queued.get(key) === entry) this.#queued.delete(key);
+ }
+ }
+
+ async #writeQueued(announce: boolean) {
+ for (const batch of batches(this.queued)) {
+ const { rid, entries } = batch;
+ for (const entry of entries)
+ this.#writing.set(cardKey(entry.card), entry);
+ const announcing = announce && batch.opts.announce;
+
+ try {
+ let rejected: string[];
+ try {
+ const cards = await this.#writer.update(batch, announcing);
+ rejected = cards.map(cardKey);
+ if (cards.length === 1) {
+ this.#writer.error(
+ "One move wasn't saved because you don't have permission.",
+ );
+ } else if (cards.length > 1) {
+ this.#writer.error(
+ `${cards.length} moves weren't saved because you don't have permission.`,
+ );
+ }
+ } catch (error) {
+ console.error("Saving the board failed", error);
+ this.#writer.error(
+ `The board couldn't be saved: ${errorMessage(error)}`,
+ );
+ this.#settle(entries);
+ await this.#writer.reload(rid);
+ continue;
+ }
+ this.#settle(entries);
+
+ const lifecycle = entries.filter(
+ e => e.state && !rejected.includes(cardKey(e.card)),
+ );
+ let changed = false;
+ for (const [index, entry] of lifecycle.entries()) {
+ try {
+ await this.#writer.setState(
+ rid,
+ entry.card.oid,
+ entry.state!,
+ announcing && index === lifecycle.length - 1,
+ );
+ changed = true;
+ } catch (error) {
+ console.error("Changing the issue's state failed", error);
+ this.#writer.error(
+ `An issue's state couldn't be changed: ${errorMessage(error)}`,
+ );
+ }
+ }
+ if (changed || rejected.length > 0) await this.#writer.reload(rid);
+ } finally {
+ for (const entry of entries) {
+ const key = cardKey(entry.card);
+ if (this.#writing.get(key) === entry) this.#writing.delete(key);
+ }
+ }
+ }
+ }
+}
diff --git a/desktop/src/lib/boardView.ts b/desktop/src/lib/boardView.ts
new file mode 100644
index 000000000..c158c92b7
--- /dev/null
+++ b/desktop/src/lib/boardView.ts
@@ -0,0 +1,342 @@
+import type { Column } from "@bindings/cob/board/Column";
+import type { Issue } from "@bindings/cob/issue/Issue";
+import type { Patch } from "@bindings/cob/patch/Patch";
+
+import { z } from "zod";
+
+import type { BoardItem } from "@app/lib/board";
+import {
+ displayLabels,
+ itemAssignees,
+ itemLabels,
+ itemTimestamp,
+ itemTitle,
+ PRIORITIES,
+ PRIORITY_RANK,
+ priorityOf,
+} from "@app/lib/board";
+
+// How a viewer arranges and filters a board. Kept per board in this browser
+// only.
+
+export const PROPERTIES = [
+ { id: "id", label: "ID" },
+ { id: "priority", label: "Priority" },
+ { id: "assignees", label: "Assignees" },
+ { id: "labels", label: "Labels" },
+ { id: "created", label: "Created" },
+ { id: "patches", label: "Patches" },
+ { id: "comments", label: "Comments" },
+ { id: "repository", label: "Repository" },
+] as const;
+
+export type Property = (typeof PROPERTIES)[number]["id"];
+
+const propertySchema = z.enum([
+ "id",
+ "priority",
+ "assignees",
+ "labels",
+ "created",
+ "patches",
+ "comments",
+ "repository",
+]);
+
+export const GROUPINGS = [
+ { id: "status", label: "Status" },
+ { id: "assignee", label: "Assignee" },
+ { id: "priority", label: "Priority" },
+ { id: "label", label: "Label" },
+ { id: "none", label: "No grouping" },
+] as const;
+
+export const ORDERINGS = [
+ { id: "manual", label: "Manual" },
+ { id: "priority", label: "Priority" },
+ { id: "created", label: "Created" },
+ { id: "title", label: "Title" },
+] as const;
+
+export type Grouping = (typeof GROUPINGS)[number]["id"];
+export type Ordering = (typeof ORDERINGS)[number]["id"];
+
+const filtersSchema = z.object({
+ types: z.array(z.enum(["issue", "patch"])),
+ assignees: z.array(z.string()),
+ priorities: z.array(z.string()),
+ labels: z.array(z.string()),
+});
+
+export type BoardFilters = z.infer;
+
+const schema = z.object({
+ grouping: z.enum(["status", "assignee", "priority", "label", "none"]),
+ ordering: z.enum(["manual", "priority", "created", "title"]),
+ descending: z.boolean(),
+ showCompleted: z.boolean(),
+ showCanceled: z.boolean(),
+ showEmpty: z.boolean(),
+ properties: z.array(propertySchema),
+ filters: filtersSchema,
+ view: z.enum(["board", "list"]),
+});
+
+export type BoardViewSettings = z.infer;
+
+export function defaultView(): BoardViewSettings {
+ return {
+ grouping: "status",
+ ordering: "manual",
+ descending: false,
+ showCompleted: true,
+ showCanceled: false,
+ showEmpty: true,
+ properties: [
+ "id",
+ "priority",
+ "assignees",
+ "labels",
+ "created",
+ "patches",
+ "comments",
+ "repository",
+ ],
+ filters: { types: [], assignees: [], priorities: [], labels: [] },
+ view: "board",
+ };
+}
+
+const KEY = "boardView";
+
+function readAll(): Record {
+ try {
+ const raw = globalThis.localStorage?.getItem(KEY);
+ const parsed: unknown = raw ? JSON.parse(raw) : {};
+ return parsed && typeof parsed === "object"
+ ? (parsed as Record)
+ : {};
+ } catch {
+ return {};
+ }
+}
+
+function keyOf(rid: string, board: string | undefined): string {
+ return board ? `${rid}/${board}` : rid;
+}
+
+// A board with nothing saved yet starts from what was saved for its
+// repository, which is where settings lived before boards had their own.
+export function loadView(
+ rid: string,
+ board: string | undefined,
+): BoardViewSettings {
+ const all = readAll();
+ const parsed = schema.partial().safeParse(all[keyOf(rid, board)] ?? all[rid]);
+ return parsed.success ? { ...defaultView(), ...parsed.data } : defaultView();
+}
+
+export function saveView(
+ rid: string,
+ board: string | undefined,
+ settings: BoardViewSettings,
+) {
+ try {
+ globalThis.localStorage?.setItem(
+ KEY,
+ JSON.stringify({ ...readAll(), [keyOf(rid, board)]: settings }),
+ );
+ } catch {
+ // Storage can be unavailable; the view still works for this session.
+ }
+}
+
+export function activeFilterCount(filters: BoardFilters): number {
+ return (
+ filters.types.length +
+ filters.assignees.length +
+ filters.priorities.length +
+ filters.labels.length
+ );
+}
+
+export function matchesFilters(
+ item: BoardItem,
+ filters: BoardFilters,
+ search: string,
+): boolean {
+ const assignees = itemAssignees(item);
+ const { types, assignees: people, priorities, labels } = filters;
+ if (
+ types.length > 0 &&
+ (item.kind === "unresolved" || !types.includes(item.kind))
+ ) {
+ return false;
+ }
+ if (
+ people.length > 0 &&
+ !(
+ (people.includes("none") && assignees.length === 0) ||
+ assignees.some(a => people.includes(a.did))
+ )
+ ) {
+ return false;
+ }
+ if (
+ priorities.length > 0 &&
+ !priorities.includes(priorityOf(itemLabels(item)))
+ ) {
+ return false;
+ }
+ if (
+ labels.length > 0 &&
+ !displayLabels(itemLabels(item)).some(l => labels.includes(l))
+ ) {
+ return false;
+ }
+ const query = search.trim().toLowerCase();
+ if (!query) return true;
+ return (
+ itemTitle(item).toLowerCase().includes(query) ||
+ item.card.oid.startsWith(query) ||
+ itemLabels(item).some(l => l.toLowerCase().includes(query)) ||
+ assignees.some(a => a.alias?.toLowerCase().includes(query))
+ );
+}
+
+export function compareItems(
+ a: BoardItem,
+ b: BoardItem,
+ ordering: Ordering,
+ descending: boolean,
+): number {
+ let order = 0;
+ if (ordering === "priority") {
+ order =
+ PRIORITY_RANK[priorityOf(itemLabels(a))] -
+ PRIORITY_RANK[priorityOf(itemLabels(b))] ||
+ itemTimestamp(b) - itemTimestamp(a);
+ } else if (ordering === "created") {
+ order = itemTimestamp(b) - itemTimestamp(a);
+ } else if (ordering === "title") {
+ order = itemTitle(a).localeCompare(itemTitle(b));
+ }
+ return descending ? -order : order;
+}
+
+// Board order, or the chosen ordering on top of it.
+export function arrange(
+ groups: Record,
+ ordering: Ordering,
+ descending: boolean,
+): Record {
+ if (ordering === "manual") return groups;
+ return Object.fromEntries(
+ Object.entries(groups).map(([id, list]) => [
+ id,
+ [...list].sort((a, b) => compareItems(a, b, ordering, descending)),
+ ]),
+ );
+}
+
+export function visibleColumns(
+ columns: Column[],
+ settings: Pick<
+ BoardViewSettings,
+ "showCanceled" | "showCompleted" | "showEmpty"
+ >,
+ groups: Record,
+ dragging: boolean,
+): Column[] {
+ return columns.filter(
+ c =>
+ (settings.showCanceled || c.closes !== "other") &&
+ (settings.showCompleted || c.closes !== "solved") &&
+ (settings.showEmpty || dragging || (groups[c.id]?.length ?? 0) > 0),
+ );
+}
+
+export interface Person {
+ did: string;
+ name: string;
+}
+
+// Everyone assigned to anything, filtered or not, so the filter menu doesn't
+// change shape as filters are picked.
+export function assigneeOptions(issues: Issue[], patches: Patch[]): Person[] {
+ const seen: Record = {};
+ for (const a of [...issues, ...patches].flatMap(i => i.assignees)) {
+ seen[a.did] = a.alias ?? `${a.did.slice(8, 16)}…`;
+ }
+ return Object.entries(seen).map(([did, name]) => ({ did, name }));
+}
+
+export function labelOptions(issues: Issue[], patches: Patch[]): string[] {
+ return [
+ ...new Set(
+ [...issues.map(i => i.labels), ...patches.map(p => p.labels)].flatMap(
+ displayLabels,
+ ),
+ ),
+ ].sort();
+}
+
+export interface ListGroup {
+ id: string;
+ name: string;
+ items: BoardItem[];
+}
+
+// Grouping by status follows the columns; the other groupings regroup the
+// cards of the visible columns.
+export function listGroups(
+ settings: Pick,
+ columns: Column[],
+ groups: Record,
+ people: Person[],
+ labels: string[],
+): ListGroup[] {
+ const shown = columns.flatMap(c => groups[c.id] ?? []);
+ const byKey = (
+ entries: { id: string; name: string }[],
+ keyOf: (item: BoardItem) => string,
+ ) =>
+ entries
+ .map(e => ({
+ ...e,
+ items: shown.filter(item => keyOf(item) === e.id),
+ }))
+ .filter(g => settings.showEmpty || g.items.length > 0);
+
+ switch (settings.grouping) {
+ case "status":
+ return columns.map(c => ({
+ id: c.id,
+ name: c.name,
+ items: groups[c.id] ?? [],
+ }));
+ case "priority":
+ return byKey(
+ PRIORITIES.map(p => ({ id: p.id, name: p.label })),
+ item => priorityOf(itemLabels(item)),
+ );
+ case "assignee":
+ return byKey(
+ [
+ ...people.map(p => ({ id: p.did, name: p.name })),
+ { id: "none", name: "No assignee" },
+ ],
+ item => itemAssignees(item)[0]?.did ?? "none",
+ );
+ case "label":
+ return byKey(
+ [
+ ...labels.map(l => ({ id: l, name: l })),
+ { id: "none", name: "No label" },
+ ],
+ item => displayLabels(itemLabels(item))[0] ?? "none",
+ );
+ default:
+ return [{ id: "all", name: "All cards", items: shown }];
+ }
+}
diff --git a/desktop/src/lib/labs.ts b/desktop/src/lib/labs.ts
new file mode 100644
index 000000000..4cb1fd40b
--- /dev/null
+++ b/desktop/src/lib/labs.ts
@@ -0,0 +1,16 @@
+import { z } from "zod";
+
+import useLocalStorage from "@app/lib/useLocalStorage.svelte";
+
+const boards = useLocalStorage("labs.boards", z.boolean(), false);
+
+// Experimental features, off until turned on in Settings.
+export const labs = {
+ get boards(): boolean {
+ return boards.value;
+ },
+
+ set boards(value: boolean) {
+ boards.value = value;
+ },
+};
diff --git a/desktop/src/lib/router.ts b/desktop/src/lib/router.ts
index 2ba46de13..1ea53bc16 100644
--- a/desktop/src/lib/router.ts
+++ b/desktop/src/lib/router.ts
@@ -2,6 +2,7 @@ import { repoRouteToPath, repoUrlToRoute } from "@app/views/repo/router";
import { on } from "svelte/events";
import { get, writable } from "svelte/store";
+import { labs } from "@app/lib/labs";
import * as mutexExecutor from "@app/lib/mutexExecutor";
import type { LoadedRoute, Route } from "@app/lib/router/definitions";
import { loadRoute } from "@app/lib/router/definitions";
@@ -126,9 +127,15 @@ const loadExecutor = mutexExecutor.create();
async function navigate(
action: "push" | "replace",
- newRoute: Route,
+ route: Route,
fragment?: string,
): Promise {
+ // Boards are a Labs feature; while it's off a board link shows the
+ // repository instead, and the address says so.
+ const hidden = route.resource === "repo.board" && !labs.boards;
+ const newRoute: Route = hidden
+ ? { resource: "repo.home", rid: route.rid }
+ : route;
const historyNav = pendingHistoryNavigation;
pendingHistoryNavigation = false;
isLoading.set(true);
@@ -150,7 +157,11 @@ async function navigate(
updateNavigationState(index, true);
} else if (action === "replace") {
const index = historyIndex();
- window.history.replaceState(stamp(newRoute, index), "");
+ window.history.replaceState(
+ stamp(newRoute, index),
+ "",
+ hidden ? path : undefined,
+ );
updateNavigationState(index);
} else {
updateNavigationState(historyIndex());
@@ -232,6 +243,7 @@ export function routeToPath(route: Route): string {
} else if (route.resource === "guide") {
return "/guide";
} else if (
+ route.resource === "repo.board" ||
route.resource === "repo.home" ||
route.resource === "repo.commits" ||
route.resource === "repo.commit" ||
diff --git a/desktop/src/lib/router/definitions.ts b/desktop/src/lib/router/definitions.ts
index ad6fde19e..1f8b10d6f 100644
--- a/desktop/src/lib/router/definitions.ts
+++ b/desktop/src/lib/router/definitions.ts
@@ -4,6 +4,7 @@ import type { Config } from "@bindings/config/Config";
import type { RepoSummary } from "@bindings/repo/RepoSummary";
import {
+ loadBoard,
loadIdentity,
loadIssue,
loadIssues,
@@ -59,6 +60,7 @@ export function isLoadedRepoRoute(
route: LoadedRoute,
): route is LoadedRepoRoute {
return (
+ route.resource === "repo.board" ||
route.resource === "repo.home" ||
route.resource === "repo.commits" ||
route.resource === "repo.commit" ||
@@ -109,6 +111,8 @@ export async function loadRoute(
return loadInbox();
} else if (route.resource === "guide") {
return loadGuide();
+ } else if (route.resource === "repo.board") {
+ return loadBoard(route);
} else if (route.resource === "repo.home") {
return loadRepoHome(route);
} else if (route.resource === "repo.commits") {
diff --git a/desktop/src/modals/AddBoardCard.svelte b/desktop/src/modals/AddBoardCard.svelte
new file mode 100644
index 000000000..be8b6afec
--- /dev/null
+++ b/desktop/src/modals/AddBoardCard.svelte
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+ {#if repo}
+
+ {/if}
+
+ {repo ? `Add from ${repo.name}` : "Add a card from another repository"}
+
+
+
+
+
+
+ {#if !repo}
+ {#each candidates as candidate (candidate.rid)}
+
+ {:else}
+
+ No other public repositories on this device. Cards from private
+ repositories can only go on their own repository's boards.
+
+ {/each}
+ {:else}
+
+ {#if loading}
+ Loading…
+ {:else}
+ {#each shown as item (cardKey(item.card))}
+ {@const added = onBoard.includes(cardKey(item.card))}
+
+ {:else}
+ No open issues or patches.
+ {/each}
+ {/if}
+ {/if}
+ {#if error}
+ {error}
+ {/if}
+
+ {rid}
+
+ This card comes from a repository your node doesn't have. Seeding it makes
+ your node fetch, replicate and announce the repository, and shows the card
+ once it arrives.
+
+ {/snippet}
+
diff --git a/desktop/src/modals/CreateBoard.svelte b/desktop/src/modals/CreateBoard.svelte
new file mode 100644
index 000000000..4f9f4f347
--- /dev/null
+++ b/desktop/src/modals/CreateBoard.svelte
@@ -0,0 +1,68 @@
+
+
+
+
+ confirm(name.trim())}>
+ {#snippet body({ working, run })}
+