Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
radicle-desktop src views repo Patches.svelte
<script lang="ts">
  import type { PatchStatus } from "@app/views/repo/router";
  import type { CacheEvent } from "@bindings/cob/CacheEvent";
  import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
  import type { Patch } from "@bindings/cob/patch/Patch";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

  import { DEFAULT_TAKE } from "@app/views/repo/router";
  import { Channel } from "@tauri-apps/api/core";
  import fuzzysort from "fuzzysort";
  import delay from "lodash/delay";

  import { invoke } from "@app/lib/invoke";
  import {
    patchCountMismatch,
    resetPatchCounts,
    updatePatchCounts,
  } from "@app/lib/patchCounts.svelte";
  import * as router from "@app/lib/router";
  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 PatchTeaser from "@app/components/PatchTeaser.svelte";
  import ScrollArea from "@app/components/ScrollArea.svelte";
  import Topbar from "@app/components/Topbar.svelte";

  import Layout from "./Layout.svelte";

  interface Props {
    repo: RepoInfo;
    patches: PaginatedQuery<Patch[]>;
    status: PatchStatus | undefined;
  }

  const { repo, patches, status }: Props = $props();

  let items = $state(patches.content);
  let cursor = patches.cursor;
  let more = patches.more;

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

  let cacheState: CacheEvent | undefined = $state();

  $effect(() => {
    items = patches.content;
    cursor = patches.cursor;
    if (patches.more === true && patches.content.length < DEFAULT_TAKE) {
      more = false;
    } else {
      more = patches.more;
    }
  });

  $effect(() => {
    if (more === false) {
      updatePatchCounts(items.length, project.meta.patches, status);
    }
  });

  $effect(() => {
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
    status;

    searchInput = "";
    showSearch = false;
  });

  async function rebuildPatchCache() {
    try {
      await invoke("rebuild_patch_cache", {
        rid: repo.rid,
        onEvent: new Channel<CacheEvent>(message => {
          cacheState = message;
        }),
      });
    } catch (error) {
      console.error(error);
    } finally {
      const p = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
        rid: repo.rid,
        skip: 0,
        status,
        take: DEFAULT_TAKE,
      });

      items = p.content;
      cursor = p.cursor;
      more = p.more;

      resetPatchCounts();

      delay(() => {
        cacheState = undefined;
      }, 1500);
    }
  }

  async function loadMoreContent(all: boolean = false) {
    if (more) {
      const p = await invoke<PaginatedQuery<Patch[]>>("list_patches", {
        rid: repo.rid,
        skip: cursor + DEFAULT_TAKE,
        status,
        take: all ? undefined : DEFAULT_TAKE,
      });

      cursor = p.cursor;
      more = p.more;

      if (all) {
        items = p.content;
      } else {
        items = [...items, ...p.content];
      }

      if (p.content.length === 0) {
        more = false;
      }

      if (more === false) {
        updatePatchCounts(items.length, project.meta.patches, status);
      }
    }
  }

  let loadingMore: boolean = $state(false);
  let loading: boolean = $state(false);
  let searchInput = $state("");
  let showSearch = $state(false);

  const searchablePatches = $derived(
    items
      .flatMap(i => {
        return {
          patch: i,
          labels: i.labels.join(" "),
          assignees: i.assignees
            .map(a => {
              return a.alias ?? "";
            })
            .join(" "),
          author: i.author.alias ?? "",
        };
      })
      .filter((item): item is NonNullable<typeof item> => item !== undefined),
  );

  const searchResults = $derived(
    fuzzysort.go(searchInput, searchablePatches, {
      keys: ["patch.title", "labels", "assignees", "author", "patch.id"],
      threshold: 0.5,
      all: true,
    }),
  );
</script>

<style>
  .page {
    display: flex;
    flex-direction: column;
    height: 100%;
  }
  .topbar-title {
    font: var(--txt-body-m-semibold);
    color: var(--color-text-secondary);
    padding-right: 0.25rem;
  }
  .filters {
    display: flex;
    align-items: center;
    gap: 0.25rem;
  }
  .filter {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    font: var(--txt-body-m-regular);
    color: var(--color-text-secondary);
    padding: 0.25rem 0.5rem;
    border-radius: var(--border-radius-sm);
    text-decoration: none;
    cursor: pointer;
    white-space: nowrap;
  }
  .filter:hover {
    background-color: var(--color-surface-subtle);
    color: var(--color-text-primary);
  }
  .filter.active {
    background-color: var(--color-surface-subtle);
  }
  .filter .global-counter-badge {
    margin-left: 0.25rem;
  }
  .filter-label {
    display: none;
  }
  .filter.active .filter-label {
    display: inline;
  }
  @media (min-width: 1011px) {
    .filter-label {
      display: inline;
    }
  }
  .list {
    display: flex;
    flex-direction: column;
    gap: 1px;
    min-height: 100%;
  }
</style>

<Layout selfScroll>
  <div class="page">
    <Topbar>
      <span class="topbar-title">Patches</span>
      <div class="filters">
        <a
          class="filter"
          class:active={status === undefined}
          href={router.routeToPath({
            resource: "repo.patches",
            rid: repo.rid,
            status: undefined,
          })}>
          <Icon name="patch" />
          <span class="filter-label">All</span>
          <span class="global-counter-badge">
            {project.meta.patches.open +
              project.meta.patches.draft +
              project.meta.patches.archived +
              project.meta.patches.merged}
          </span>
        </a>
        <a
          class="filter"
          class:active={status === "open"}
          href={router.routeToPath({
            resource: "repo.patches",
            rid: repo.rid,
            status: "open",
          })}>
          <Icon name="patch" />
          <span class="filter-label">Open</span>
          <span class="global-counter-badge">{project.meta.patches.open}</span>
        </a>
        <a
          class="filter"
          class:active={status === "merged"}
          href={router.routeToPath({
            resource: "repo.patches",
            rid: repo.rid,
            status: "merged",
          })}>
          <Icon name="patch-merged" />
          <span class="filter-label">Merged</span>
          <span class="global-counter-badge">
            {project.meta.patches.merged}
          </span>
        </a>
        <a
          class="filter"
          class:active={status === "archived"}
          href={router.routeToPath({
            resource: "repo.patches",
            rid: repo.rid,
            status: "archived",
          })}>
          <Icon name="patch-archived" />
          <span class="filter-label">Archived</span>
          <span class="global-counter-badge">
            {project.meta.patches.archived}
          </span>
        </a>
        <a
          class="filter"
          class:active={status === "draft"}
          href={router.routeToPath({
            resource: "repo.patches",
            rid: repo.rid,
            status: "draft",
          })}>
          <Icon name="patch-draft" />
          <span class="filter-label">Drafts</span>
          <span class="global-counter-badge">{project.meta.patches.draft}</span>
        </a>
      </div>
      <div class="global-flex" style:margin-left="auto" style:gap="0.5rem">
        <FuzzySearch
          hasItems={items.length > 0}
          placeholder={`Fuzzy filter patches ${modifierKey()} + f`}
          icon={loading ? "clock" : "filter"}
          onFocus={async () => {
            try {
              loading = true;
              await loadMoreContent(true);
            } catch (e) {
              console.error("Loading all patches failed: ", e);
            } finally {
              loading = false;
            }
          }}
          onSubmit={async () => {
            if (searchResults.length === 1) {
              await router.push({
                patch: searchResults[0].obj.patch.id,
                resource: "repo.patch",
                reviewId: undefined,
                rid: repo.rid,
                status,
              });
            }
          }}
          bind:show={showSearch}
          bind:value={searchInput} />
        <NewPatchButton rid={repo.rid} />
      </div>
    </Topbar>

    <ScrollArea
      style="height: 100%; min-width: 0;"
      onScrollHalf={() => {
        if (!loadingMore) {
          loadingMore = true;
          void loadMoreContent().finally(() => (loadingMore = false));
        }
      }}>
      {#if patchCountMismatch(status)}
        <CobCacheWarning
          noun="patches"
          {cacheState}
          onRebuild={rebuildPatchCache} />
      {/if}

      <div class="list">
        {#each searchResults as result}
          <PatchTeaser
            focussed={searchResults.length === 1 && searchInput !== ""}
            patch={result.obj.patch}
            rid={repo.rid}
            {status} />
        {/each}

        {#if searchResults.length === 0}
          <div
            class="global-flex"
            style:flex="1"
            style:justify-content="center"
            style:align-items="center">
            <div
              class="txt-missing txt-body-m-regular global-flex"
              style:gap="0.25rem">
              {#if items.length > 0 && searchResults.length === 0}
                No matching patches
              {:else}
                No {status === undefined ? "" : status} patches
              {/if}
            </div>
          </div>
        {/if}
      </div>
    </ScrollArea>
  </div>
</Layout>