Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Use distinct icons for issue/patch states
Open rudolfs opened 1 year ago
15 files changed +382 -92 296c046e c2f38bb0
modified src/components/Icon.svelte
@@ -32,16 +32,19 @@
      | "home"
      | "inbox"
      | "issue"
+
      | "issue-closed"
      | "label"
      | "lock"
      | "markdown"
-
      | "merge"
      | "moon"
      | "more-vertical"
      | "none"
      | "offline"
      | "online"
      | "patch"
+
      | "patch-archived"
+
      | "patch-draft"
+
      | "patch-merged"
      | "pen"
      | "plus"
      | "reply"
@@ -501,6 +504,31 @@
    <path d="M7 8.99999H9V9.99998H7V8.99999Z" />
    <path d="M10 6.99998V8.99998L9 8.99999L9 6.99999L10 6.99998Z" />
    <path d="M7 6.99999L7 8.99999L6 8.99998V6.99998L7 6.99999Z" />
+
  {:else if name === "issue-closed"}
+
    <path d="M6 13H8V14H6V13Z" />
+
    <path d="M10 13H8V14H10V13Z" />
+
    <path d="M3 6L3 8H2L2 6H3Z" />
+
    <path d="M13 6V8H14V6H13Z" />
+
    <path d="M4 12H6V13H4V12Z" />
+
    <path d="M12 12H10V13H12V12Z" />
+
    <path d="M4 4V6H3L3 4H4Z" />
+
    <path d="M12 4V6L13 6V4L12 4Z" />
+
    <path d="M4 10L4 12H3L3 10H4Z" />
+
    <path d="M12 10V12H13V10H12Z" />
+
    <path d="M6 4L4 4L4 3L6 3V4Z" />
+
    <path d="M10 4L12 4V3L10 3V4Z" />
+
    <path d="M3 8L3 10H2L2 8H3Z" />
+
    <path d="M13 8V10H14V8H13Z" />
+
    <path d="M8 3L6 3V2L8 2V3Z" />
+
    <path d="M8 3L10 3L10 2L8 2V3Z" />
+
    <path d="M7 7H8V8H7V7Z" />
+
    <path d="M8 8L9 8V9H8V8Z" />
+
    <path d="M9 9L10 9V10H9V9Z" />
+
    <path d="M9 6H10V7H9V6Z" />
+
    <path d="M6 9H7V10H6V9Z" />
+
    <path d="M7 8H8V9L7 9L7 8Z" />
+
    <path d="M8 7H9V8L8 8V7Z" />
+
    <path d="M6 6H7V7L6 7V6Z" />
  {:else if name === "label"}
    <path d="M8.5 2.50003H11.5V3.50003H8.5V2.50003Z" />
    <path d="M12.5 4.50003H13.5V7.50003H12.5V4.50003Z" />
@@ -559,16 +587,6 @@
    <path d="M8 11H9V12H8V11Z" />
    <path d="M7 10H8V12H7V10Z" />
    <path d="M3 4.00003H4V6.00003H3V4.00003Z" />
-
  {:else if name === "merge"}
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M13 11H14V14H11L11 11H12V7H13V11ZM12 13L12 12H13L13 13H12Z" />
-
    <path d="M12 7L9 7L9 9H8L8 8L7 8V7H6V6L7 6V5H8L8 4L9 4V6L12 6L12 7Z" />
-
    <path
-
      fill-rule="evenodd"
-
      clip-rule="evenodd"
-
      d="M3 5H2L2 2L5 2L5 5H4L4 11H5L5 14H2L2 11H3L3 5ZM4 4V3H3L3 4L4 4ZM3 12H4L4 13H3V12Z" />
  {:else if name === "moon"}
    <path d="M4 3H6V4H4V3Z" />
    <path d="M3 4L4 4L4 6H3V4Z" />
@@ -696,13 +714,39 @@
    <path d="M5 13H6V14H5V13Z" />
    <path d="M11 2.99998L10 2.99998V1.99998L11 1.99998V2.99998Z" />
  {:else if name === "patch"}
-
    <path d="M2 3H3V13H2V3Z" />
-
    <path d="M3 13H12V14H3L3 13Z" />
-
    <path d="M3 2H12V3L3 3L3 2Z" />
-
    <path d="M12 3L13 3V13H12V3Z" />
-
    <path d="M7 4H8V9H7V4Z" />
-
    <path d="M5 6H10V7H5V6Z" />
-
    <path d="M5 10H10V11H5V10Z" />
+
    <path d="M13 11H14V14H11L11 11H12V7H13V11ZM12 13L12 12H13L13 13H12Z" />
+
    <path d="M12 7L9 7L9 9H8L8 8L7 8V7H6V6L7 6V5H8L8 4L9 4V6L12 6L12 7Z" />
+
    <path
+
      d="M3 5H2L2 2L5 2L5 5H4L4 11H5L5 14H2L2 11H3L3 5ZM4 4V3H3L3 4L4 4ZM3 12H4L4 13H3V12Z" />
+
  {:else if name === "patch-archived"}
+
    <path d="M9 6H12V7H9V6Z" />
+
    <path d="M12 7L13 7V12H12V7Z" />
+
    <path d="M3 5H4V12H3V5Z" />
+
    <path d="M4 12H3L3 13H4L4 12ZM2 11V14H5V11H2Z" />
+
    <path d="M4 3H3V4H4V3ZM2 2V5H5V2H2Z" />
+
    <path d="M9 14H8L8 13H9L9 14Z" />
+
    <path d="M10 13H9V12H10V13Z" />
+
    <path d="M7 13H8V12H7V13Z" />
+
    <path d="M6 12V11H11V12H6Z" />
+
    <path d="M8 7H9V13H8V7Z" />
+
    <path d="M11 12H12V11H14V14H11V12ZM12 12H13V13H12V12Z" />
+
  {:else if name === "patch-draft"}
+
    <path d="M11 9H12V10H11V9Z" />
+
    <path d="M11 7H12V8H11V7Z" />
+
    <path d="M11 5H12V6H11V5Z" />
+
    <path d="M11 3H12V4H11V3Z" />
+
    <path d="M4 5H5V11H4V5Z" />
+
    <path d="M12 12H11V13H12V12ZM10 11V14H13V11H10Z" />
+
    <path d="M5 12H4V13H5V12ZM3 11V14H6V11H3Z" />
+
    <path d="M5 3H4V4H5V3ZM3 2V5H6V2H3Z" />
+
  {:else if name === "patch-merged"}
+
    <path d="M5 3.00003H11V4.00003H5V3.00003Z" />
+
    <path d="M11 4.00003L12 4.00003V11H11V4.00003Z" />
+
    <path d="M4 5.00003H5V11H4V5.00003Z" />
+
    <path d="M12 12H11V13H12V12ZM10 11V14H13V11H10Z" />
+
    <path d="M5 12H4V13H5V12ZM3 11V14H6V11H3Z" />
+
    <path
+
      d="M5 3.00003L4 3.00003V4.00003L5 4.00003V3.00003ZM3 2.00003V5.00003H6V2.00003H3Z" />
  {:else if name === "pen"}
    <path d="M13 4.99998H14V5.99999H13V4.99998Z" />
    <path d="M2 13H3V14H2V13Z" />
modified src/components/IssueSecondColumn.svelte
@@ -3,11 +3,18 @@
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
  import type { IssueStatus } from "@app/views/repo/router";

+
  import capitalize from "lodash/capitalize";
+

  import * as router from "@app/lib/router";
+
  import { issueStatusColor } from "@app/lib/utils";

-
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
+
  import Border from "./Border.svelte";
+
  import DropdownList from "./DropdownList.svelte";
+
  import DropdownListItem from "./DropdownListItem.svelte";
  import Icon from "./Icon.svelte";
+
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
  import OutlineButton from "./OutlineButton.svelte";
+
  import Popover, { closeFocused } from "./Popover.svelte";

  const activeRouteStore = router.activeRouteStore;

@@ -17,11 +24,15 @@
    issues: Issue[];
    status: IssueStatus;
    title: string;
+
    changeFilter: (status: IssueStatus) => void;
  }

  /* eslint-disable prefer-const */
-
  let { repo, selectedIssueId, issues, status, title }: Props = $props();
+
  let { repo, selectedIssueId, issues, status, title, changeFilter }: Props =
+
    $props();
  /* eslint-enable prefer-const */
+

+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
</script>

<style>
@@ -40,25 +51,79 @@
  }
</style>

+
{#snippet icons(status: IssueStatus)}
+
  <div
+
    class="icon"
+
    style:color={status === "all" ? undefined : issueStatusColor[status]}>
+
    <Icon name={status === "closed" ? "issue-closed" : "issue"} />
+
  </div>
+
{/snippet}
+

+
{#snippet counters(status: IssueStatus)}
+
  <div style:margin-left="auto" style:padding-left="0.25rem">
+
    {#if status === "all"}
+
      {project.meta.issues.open + project.meta.issues.closed}
+
    {:else}
+
      {project.meta.issues[status]}
+
    {/if}
+
  </div>
+
{/snippet}
+

<div class="container">
-
  <div class="txt-regular txt-semibold global-flex" style:gap="4px">
+
  <div
+
    class="txt-regular txt-semibold global-flex"
+
    style:gap="4px"
+
    style:white-space="nowrap">
    {title}
    <Icon name="chevron-right" />
    Issues
  </div>

-
  <OutlineButton
-
    variant="ghost"
-
    disabled={$activeRouteStore.resource === "repo.createIssue"}
-
    onclick={() => {
-
      void router.push({
-
        resource: "repo.createIssue",
-
        rid: repo.rid,
-
        status,
-
      });
-
    }}>
-
    <Icon name="plus" />New
-
  </OutlineButton>
+
  <div class="global-flex" style:margin-left="1rem">
+
    <Popover popoverPositionRight="0" popoverPositionTop="2.5rem">
+
      {#snippet toggle(onclick)}
+
        <OutlineButton variant="ghost" {onclick}>
+
          {@render icons(status)}
+
          {capitalize(status)}
+
          {@render counters(status)}
+
          <Icon name="chevron-down" />
+
        </OutlineButton>
+
      {/snippet}
+

+
      {#snippet popover()}
+
        <Border variant="ghost">
+
          <DropdownList items={["all", "open", "closed"] as IssueStatus[]}>
+
            {#snippet item(state)}
+
              <DropdownListItem
+
                style="gap: 0.5rem"
+
                selected={status === state}
+
                onclick={() => {
+
                  changeFilter(state);
+
                  closeFocused();
+
                }}>
+
                {@render icons(state)}
+
                {capitalize(state)}
+
                {@render counters(state)}
+
              </DropdownListItem>
+
            {/snippet}
+
          </DropdownList>
+
        </Border>
+
      {/snippet}
+
    </Popover>
+

+
    <OutlineButton
+
      variant="ghost"
+
      disabled={$activeRouteStore.resource === "repo.createIssue"}
+
      onclick={() => {
+
        void router.push({
+
          resource: "repo.createIssue",
+
          rid: repo.rid,
+
          status,
+
        });
+
      }}>
+
      <Icon name="plus" />New
+
    </OutlineButton>
+
  </div>
</div>
<div class="issue-list">
  {#each issues as issue}
@@ -69,4 +134,26 @@
      rid={repo.rid}
      selected={issue.id === selectedIssueId} />
  {/each}
+

+
  {#if issues.length === 0}
+
    <Border
+
      styleMinWidth="25rem"
+
      variant="ghost"
+
      styleAlignItems="center"
+
      styleJustifyContent="center">
+
      <div
+
        class="global-flex"
+
        style:height="74px"
+
        style:justify-content="center">
+
        <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
+
          <Icon name="none" />
+
          {#if status === "all"}
+
            No issues.
+
          {:else}
+
            No {status} issues.
+
          {/if}
+
        </div>
+
      </div>
+
    </Border>
+
  {/if}
</div>
modified src/components/IssueTeaser.svelte
@@ -81,7 +81,11 @@
      class="global-counter status"
      style:color={issueStatusColor[issue.state.status]}
      style:background-color={issueStatusBackgroundColor[issue.state.status]}>
-
      <Icon name="issue" />
+
      {#if issue.state.status === "open"}
+
        <Icon name="issue" />
+
      {:else}
+
        <Icon name="issue-closed" />
+
      {/if}
    </div>
    <div
      class="global-flex"
modified src/components/IssueTimeline.svelte
@@ -108,7 +108,11 @@
    {#if op.type === "lifecycle"}
      <div class="timeline-item">
        <div class="icon" style:color={issueStatusColor[op.state.status]}>
-
          <Icon name="issue" />
+
          {#if op.state.status === "open"}
+
            <Icon name="issue" />
+
          {:else}
+
            <Icon name="issue-closed" />
+
          {/if}
        </div>
        <div class="wrapper">
          <NodeId {...authorForNodeId(op.author)} />
modified src/components/IssuesSecondColumn.svelte
@@ -1,6 +1,5 @@
<script lang="ts">
  import type { IssueStatus } from "@app/views/repo/router";
-
  import type { ProjectPayload } from "@bindings/repo/ProjectPayload";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

  import Border from "./Border.svelte";
@@ -10,12 +9,13 @@
  import Settings from "./Settings.svelte";

  interface Props {
-
    project: ProjectPayload;
    status: IssueStatus;
    repo: RepoInfo;
  }

-
  const { project, status, repo }: Props = $props();
+
  const { status, repo }: Props = $props();
+

+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
</script>

<style>
@@ -111,7 +111,7 @@
          <div
            class="global-flex"
            class:closed={["closed", "all"].includes(status)}>
-
            <Icon name="issue" />Closed
+
            <Icon name="issue-closed" />Closed
          </div>
          <div class="global-counter" class:highlight={status === "all"}>
            {project.meta.issues.closed}
modified src/components/PatchTeaser.svelte
@@ -89,7 +89,10 @@
      class="global-counter status"
      style:color={patchStatusColor[patch.state.status]}
      style:background-color={patchStatusBackgroundColor[patch.state.status]}>
-
      <Icon name="patch" />
+
      <Icon
+
        name={patch.state.status === "open"
+
          ? "patch"
+
          : `patch-${patch.state.status}`} />
    </div>
    <div
      class="global-flex"
modified src/components/PatchTimeline.svelte
@@ -128,7 +128,10 @@
    {:else if op.type === "lifecycle"}
      <div class="timeline-item">
        <div class="icon" style:color={patchStatusColor[op.state.status]}>
-
          <Icon name="patch" />
+
          <Icon
+
            name={op.state.status === "open"
+
              ? "patch"
+
              : `patch-${op.state.status}`} />
        </div>
        <div class="wrapper">
          <NodeId {...authorForNodeId(op.author)} />
@@ -213,7 +216,7 @@
    {:else if op.type === "merge"}
      <div class="timeline-item">
        <div class="icon" style:color="var(--color-fill-primary)">
-
          <Icon name="patch" />
+
          <Icon name="patch-merged" />
        </div>
        <div class="wrapper">
          <NodeId {...authorForNodeId(op.author)} />
modified src/components/PatchesSecondColumn.svelte
@@ -137,7 +137,7 @@
          <div
            class="global-flex"
            class:merged={["merged", undefined].includes(status)}>
-
            <Icon name="patch" />Merged
+
            <Icon name="patch-merged" />Merged
          </div>
          <div class="global-counter" class:highlight={status === undefined}>
            {project.meta.patches.merged}
@@ -157,7 +157,7 @@
          <div
            class="global-flex"
            class:archived={["archived", undefined].includes(status)}>
-
            <Icon name="patch" />Archived
+
            <Icon name="patch-archived" />Archived
          </div>
          <div class="global-counter" class:highlight={status === undefined}>
            {project.meta.patches.archived}
@@ -176,7 +176,7 @@
          <div
            class="global-flex"
            class:draft={["draft", undefined].includes(status)}>
-
            <Icon name="patch" />
+
            <Icon name="patch-draft" />
            Draft
          </div>
          <div class="global-counter" class:highlight={status === undefined}>
modified src/components/RevisionSelector.svelte
@@ -88,7 +88,7 @@
                    <div class="icon">
                      {#if patch.state.status === "merged" && patch.state.revision === revision.id}
                        <div style:color="var(--color-fill-primary)">
-
                          <Icon name="merge" />
+
                          <Icon name="patch-merged" />
                        </div>
                      {:else if revision.reviews && revision.reviews.length > 0 && revision.reviews.every( r => {
                            return r.verdict === "accept";
modified src/components/Sidebar.svelte
@@ -2,8 +2,6 @@
  import type { IssueStatus, PatchStatus } from "@app/views/repo/router";

  import * as router from "@app/lib/router";
-
  import { patchStatusColor } from "@app/lib/utils";
-
  import { issueStatusColor } from "@app/lib/utils";

  import { storeLayout, getLayout } from "@app/views/repo/Layout.svelte";

@@ -61,12 +59,7 @@
      styleWidth="40px"
      styleHeight="40px"
      styleJustifyContent="center">
-
      <div
-
        style:color={activeTab.status === "all"
-
          ? undefined
-
          : issueStatusColor[activeTab.status]}>
-
        <Icon name="issue" />
-
      </div>
+
      <Icon name="issue" />
    </Border>
  {:else}
    <button
@@ -96,12 +89,7 @@
      styleWidth="40px"
      styleHeight="40px"
      styleJustifyContent="center">
-
      <div
-
        style:color={activeTab.status
-
          ? patchStatusColor[activeTab.status]
-
          : undefined}>
-
        <Icon name="patch" />
-
      </div>
+
      <Icon name="patch" />
    </Border>
  {:else}
    <button
modified src/views/repo/CreateIssue.svelte
@@ -31,16 +31,35 @@
    status: IssueStatus;
  }

-
  const { repo, issues, config, status }: Props = $props();
+
  const {
+
    repo,
+
    issues: initialIssues,
+
    config,
+
    status: initialStatus,
+
  }: Props = $props();

  const project = $derived(repo.payloads["xyz.radicle.project"]!);

  let preview: boolean = $state(false);
  let title: string = $state("");
+
  let status = $state(initialStatus);
+
  let issues = $state(initialIssues);

  let assignees: Author[] = $state([]);
  let labels: string[] = $state([]);

+
  async function loadIssues(filter: IssueStatus) {
+
    try {
+
      issues = await invoke<Issue[]>("list_issues", {
+
        rid: repo.rid,
+
        status: filter,
+
      });
+
      status = filter;
+
    } catch (error) {
+
      console.error("Loading issue list failed", error);
+
    }
+
  }
+

  async function createIssue(
    description: string,
    embeds: Embed[],
@@ -97,7 +116,14 @@
  {/snippet}

  {#snippet secondColumn()}
-
    <IssueSecondColumn {repo} {issues} {status} title={project.data.name} />
+
    <IssueSecondColumn
+
      {repo}
+
      {issues}
+
      {status}
+
      title={project.data.name}
+
      changeFilter={async filter => {
+
        await loadIssues(filter);
+
      }} />
  {/snippet}

  <div class="content">
modified src/views/repo/Issue.svelte
@@ -60,12 +60,12 @@
    activity,
    config,
    threads,
-
    status,
+
    status: initialStatus,
  }: Props = $props();
  /* eslint-enable prefer-const */

-
  const issues = $state(initialIssues);
-

+
  let issues = $state(initialIssues);
+
  let status = $state(initialStatus);
  let topLevelReplyOpen = $state(false);
  let editingTitle = $state(false);
  let updatedTitle = $state("");
@@ -94,6 +94,18 @@

  const project = $derived(repo.payloads["xyz.radicle.project"]!);

+
  async function loadIssues(filter: IssueStatus) {
+
    try {
+
      issues = await invoke<Issue[]>("list_issues", {
+
        rid: repo.rid,
+
        status: filter,
+
      });
+
      status = filter;
+
    } catch (error) {
+
      console.error("Loading issue list failed", error);
+
    }
+
  }
+

  async function saveLabels(labels: string[]) {
    try {
      labelSaveInProgress = true;
@@ -318,7 +330,7 @@
    display: flex;
    align-items: center;
    justify-content: space-between;
-
    word-break: break-all;
+
    word-break: break-word;
    min-height: 40px;
  }
  .status {
@@ -397,6 +409,9 @@
      selectedIssueId={issue.id}
      {issues}
      {status}
+
      changeFilter={async filter => {
+
        await loadIssues(filter);
+
      }}
      title={project.data.name} />
  {/snippet}

@@ -410,7 +425,11 @@
            style:background-color={issueStatusBackgroundColor[
              issue.state.status
            ]}>
-
            <Icon name="issue" />
+
            {#if issue.state.status === "open"}
+
              <Icon name="issue" />
+
            {:else}
+
              <Icon name="issue-closed" />
+
            {/if}
          </div>
          <TextInput
            valid={updatedTitle.trim().length > 0}
@@ -451,7 +470,11 @@
              style:background-color={issueStatusBackgroundColor[
                issue.state.status
              ]}>
-
              <Icon name="issue" />
+
              {#if issue.state.status === "open"}
+
                <Icon name="issue" />
+
              {:else}
+
                <Icon name="issue-closed" />
+
              {/if}
            </div>
            <InlineTitle content={issue.title} fontSize="medium" />
          </div>
modified src/views/repo/Issues.svelte
@@ -8,13 +8,13 @@

  import Layout from "./Layout.svelte";

+
  import Border from "@app/components/Border.svelte";
  import Button from "@app/components/Button.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
  import Icon from "@app/components/Icon.svelte";
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
  import IssuesSecondColumn from "@app/components/IssuesSecondColumn.svelte";
  import Sidebar from "@app/components/Sidebar.svelte";
-
  import Border from "@app/components/Border.svelte";

  interface Props {
    repo: RepoInfo;
@@ -24,8 +24,6 @@
  }

  const { repo, issues, config, status }: Props = $props();
-

-
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
</script>

<style>
@@ -61,7 +59,7 @@
  {/snippet}

  {#snippet secondColumn()}
-
    <IssuesSecondColumn {project} {status} {repo} />
+
    <IssuesSecondColumn {status} {repo} />
  {/snippet}

  <div class="container">
modified src/views/repo/Layout.svelte
@@ -117,7 +117,7 @@

  .secondColumn {
    grid-column: 2 / 3;
-
    max-width: 28rem;
+
    max-width: 29rem;
    min-width: 14rem;
    padding: 1rem 1rem 1rem 0;
  }
modified src/views/repo/Patch.svelte
@@ -9,6 +9,8 @@
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
  import type { Revision } from "@bindings/cob/patch/Revision";

+
  import capitalize from "lodash/capitalize";
+

  import * as roles from "@app/lib/roles";
  import { announce } from "@app/components/AnnounceSwitch.svelte";
  import {
@@ -22,14 +24,18 @@
  import AssigneeInput from "@app/components/AssigneeInput.svelte";
  import Border from "@app/components/Border.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownListItem.svelte";
  import Icon from "@app/components/Icon.svelte";
  import InlineTitle from "@app/components/InlineTitle.svelte";
  import LabelInput from "@app/components/LabelInput.svelte";
  import Layout from "./Layout.svelte";
+
  import OutlineButton from "@app/components/OutlineButton.svelte";
  import PatchStateBadge from "@app/components/PatchStateBadge.svelte";
  import PatchStateButton from "@app/components/PatchStateButton.svelte";
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
  import PatchTimeline from "@app/components/PatchTimeline.svelte";
+
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
  import RevisionBadges from "@app/components/RevisionBadges.svelte";
  import RevisionComponent from "@app/components/Revision.svelte";
  import RevisionSelector from "@app/components/RevisionSelector.svelte";
@@ -51,7 +57,7 @@
  let {
    repo,
    patch,
-
    patches,
+
    patches: initialPatches,
    revisions,
    config,
    status: initialStatus,
@@ -59,9 +65,11 @@
  }: Props = $props();
  /* eslint-enable prefer-const */

-
  let cursor = patches.cursor;
-
  let more = patches.more;
-
  let items = $state(patches.content);
+
  let cursor: number = $state(0);
+
  let more: boolean = $state(false);
+
  let patchTeasers: Patch[] = $state([]);
+

+
  let patches = $state(initialPatches);
  let status = $state(initialStatus);
  let editingTitle = $state(false);
  let updatedTitle = $state("");
@@ -72,7 +80,7 @@
  let selectedRevision: Revision = $state(revisions.slice(-1)[0]);

  $effect(() => {
-
    items = patches.content;
+
    patchTeasers = patches.content;
    cursor = patches.cursor;
    more = patches.more;
  });
@@ -192,7 +200,7 @@
    }
  }

-
  async function loadMoreSecondColumn() {
+
  async function loadMoreTeasers() {
    if (more) {
      const p = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
        rid: repo.rid,
@@ -202,7 +210,7 @@

      cursor = p.cursor;
      more = p.more;
-
      items = [...items, ...p.content];
+
      patchTeasers = [...patchTeasers, ...p.content];
    }
  }

@@ -230,6 +238,18 @@
      }),
    ]);
  }
+

+
  async function loadPatches(filter: PatchStatus | undefined) {
+
    try {
+
      patches = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
+
        rid: repo.rid,
+
        status: filter,
+
      });
+
      status = filter;
+
    } catch (error) {
+
      console.error("Loading patch list failed", error);
+
    }
+
  }
</script>

<style>
@@ -241,7 +261,7 @@
    display: flex;
    align-items: center;
    justify-content: space-between;
-
    word-break: break-all;
+
    word-break: break-word;
    min-height: 40px;
  }
  .title-icons {
@@ -291,7 +311,29 @@
  }
</style>

-
<Layout {loadMoreSecondColumn} publicKey={config.publicKey}>
+
{#snippet icons(status: PatchStatus | undefined)}
+
  <div class="icon" style:color={status ? patchStatusColor[status] : undefined}>
+
    <Icon
+
      name={status === undefined || status === "open"
+
        ? "patch"
+
        : `patch-${status}`} />
+
  </div>
+
{/snippet}
+

+
{#snippet counters(status: PatchStatus | undefined)}
+
  <div style:margin-left="auto" style:padding-left="0.25rem">
+
    {#if status}
+
      {project.meta.patches[status]}
+
    {:else}
+
      {project.meta.patches.draft +
+
        project.meta.patches.open +
+
        project.meta.patches.archived +
+
        project.meta.patches.merged}
+
    {/if}
+
  </div>
+
{/snippet}
+

+
<Layout loadMoreSecondColumn={loadMoreTeasers} publicKey={config.publicKey}>
  {#snippet headerCenter()}
    <CopyableId id={patch.id} />
  {/snippet}
@@ -303,22 +345,84 @@
  {#snippet secondColumn()}
    <div
      class="txt-regular txt-semibold global-flex"
-
      style:gap="4px"
-
      style:min-height="40px">
-
      {project.data.name}
-
      <Icon name="chevron-right" />
-
      Patches
+
      style:min-height="40px"
+
      style:justify-content="space-between">
+
      <div class="global-flex" style:gap="4px">
+
        {project.data.name}
+
        <Icon name="chevron-right" />
+
        Patches
+
      </div>
+

+
      <Popover popoverPositionRight="0" popoverPositionTop="2.5rem">
+
        {#snippet toggle(onclick)}
+
          <OutlineButton variant="ghost" {onclick}>
+
            {@render icons(status)}
+
            {status ? capitalize(status) : "All"}
+
            {@render counters(status)}
+
            <Icon name="chevron-down" />
+
          </OutlineButton>
+
        {/snippet}
+

+
        {#snippet popover()}
+
          <Border variant="ghost">
+
            <DropdownList
+
              items={[
+
                undefined,
+
                "draft",
+
                "open",
+
                "archived",
+
                "merged",
+
              ] as const}>
+
              {#snippet item(state)}
+
                <DropdownListItem
+
                  style="gap: 0.5rem"
+
                  selected={status === state}
+
                  onclick={async () => {
+
                    await loadPatches(state);
+
                    closeFocused();
+
                  }}>
+
                  {@render icons(state)}
+
                  {state ? capitalize(state) : "All"}
+
                  {@render counters(state)}
+
                </DropdownListItem>
+
              {/snippet}
+
            </DropdownList>
+
          </Border>
+
        {/snippet}
+
      </Popover>
    </div>
    <div class="patch-list">
-
      {#each items as p}
+
      {#each patchTeasers as teaser}
        <PatchTeaser
          compact
          {loadPatch}
-
          patch={p}
+
          patch={teaser}
          rid={repo.rid}
          {status}
-
          selected={patch && p.id === patch.id} />
+
          selected={patch && teaser.id === patch.id} />
      {/each}
+

+
      {#if patches.content.length === 0}
+
        <Border
+
          styleMinWidth="25rem"
+
          variant="ghost"
+
          styleAlignItems="center"
+
          styleJustifyContent="center">
+
          <div
+
            class="global-flex"
+
            style:height="74px"
+
            style:justify-content="center">
+
            <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
+
              <Icon name="none" />
+
              {#if status === undefined}
+
                No patches.
+
              {:else}
+
                No {status} patches.
+
              {/if}
+
            </div>
+
          </div>
+
        </Border>
+
      {/if}
    </div>
  {/snippet}

@@ -332,7 +436,10 @@
            style:background-color={patchStatusBackgroundColor[
              patch.state.status
            ]}>
-
            <Icon name="patch" />
+
            <Icon
+
              name={patch.state.status === "open"
+
                ? "patch"
+
                : `patch-${patch.state.status}`} />
          </div>

          <TextInput
@@ -374,7 +481,10 @@
              style:background-color={patchStatusBackgroundColor[
                patch.state.status
              ]}>
-
              <Icon name="patch" />
+
              <Icon
+
                name={patch.state.status === "open"
+
                  ? "patch"
+
                  : `patch-${patch.state.status}`} />
            </div>
            <InlineTitle content={patch.title} fontSize="medium" />
          </div>