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

  import { COMMITS_PAGE_SIZE } from "@app/views/repo/router";
  import fuzzysort from "fuzzysort";

  import { cachedRepoCommitCount } from "@app/lib/invoke";
  import { invoke } from "@app/lib/invoke";
  import * as mutexExecutor from "@app/lib/mutexExecutor";
  import * as router from "@app/lib/router";
  import { modifierKey } from "@app/lib/utils";

  import CobCommitTeaser from "@app/components/CobCommitTeaser.svelte";
  import FuzzySearch from "@app/components/FuzzySearch.svelte";
  import Icon from "@app/components/Icon.svelte";
  import InfiniteScrollSentinel from "@app/components/InfiniteScrollSentinel.svelte";
  import Topbar from "@app/components/Topbar.svelte";

  import Layout from "./Layout.svelte";

  interface Props {
    repo: RepoInfo;
    commits: PaginatedQuery<Commit[]>;
  }

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

  const project = $derived(repo.payloads["xyz.radicle.project"]!);
  let commitCount: number | undefined = $state();

  $effect(() => {
    const rid = repo.rid;
    commitCount = undefined;
    void cachedRepoCommitCount(rid, project.meta.head)
      .then(count => {
        if (repo.rid === rid) {
          commitCount = count;
        }
      })
      .catch(error => {
        console.error("Failed to load commit count", error);
      });
  });

  type CommitGroup = {
    key: string;
    label: string;
    commits: Commit[];
  };

  let items = $state(commits.content);
  let more = $state(commits.more);
  let loadingMore = $state(false);
  let loading = $state(false);
  let searchInput = $state("");
  let debouncedSearch = $state("");
  let showSearch = $state(false);

  const loader = mutexExecutor.create();
  const abort = async (): Promise<undefined> => undefined;

  $effect(() => {
    items = commits.content;
    more = commits.more;
    // Abort any in-flight loadMoreContent so it cannot append a page
    // from the previous navigation onto the just-reset items.
    void loader.run(abort);
  });

  $effect(() => {
    const value = searchInput;
    const timer = setTimeout(() => {
      debouncedSearch = value;
    }, 150);
    return () => clearTimeout(timer);
  });

  async function loadMoreContent(all: boolean = false): Promise<void> {
    if (!more) return;
    loadingMore = true;
    try {
      const page = await loader.run(async () => {
        return await invoke<PaginatedQuery<Commit[]>>("list_repo_commits", {
          rid: repo.rid,
          head: project.meta.head,
          skip: all ? 0 : items.length,
          take: all ? undefined : COMMITS_PAGE_SIZE,
        });
      });

      // Superseded by a newer load (e.g. fuzzy-focus triggered a load-all).
      // Leave items/more alone for the new call.
      if (page === undefined) return;

      more = page.more;
      items = all ? page.content : [...items, ...page.content];
      if (page.content.length === 0) more = false;
    } finally {
      loadingMore = false;
    }
  }

  function dayKey(timestamp: number) {
    const date = new Date(timestamp);
    const month = `${date.getMonth() + 1}`.padStart(2, "0");
    const day = `${date.getDate()}`.padStart(2, "0");

    return `${date.getFullYear()}-${month}-${day}`;
  }

  function dayLabel(timestamp: number) {
    const date = new Date(timestamp);
    const today = new Date();
    const yesterday = new Date();
    yesterday.setDate(today.getDate() - 1);

    if (dayKey(date.getTime()) === dayKey(today.getTime())) {
      return "Today";
    }
    if (dayKey(date.getTime()) === dayKey(yesterday.getTime())) {
      return "Yesterday";
    }

    return new Intl.DateTimeFormat("en", {
      weekday: "long",
      month: "long",
      day: "numeric",
      year: "numeric",
    }).format(date);
  }

  const searchableCommits = $derived(
    items.map(c => ({
      commit: c,
      id: c.id,
      summary: c.summary,
      author: c.author.name,
    })),
  );

  const searchResults = $derived(
    fuzzysort.go(debouncedSearch, searchableCommits, {
      keys: ["summary", "id", "author"],
      threshold: 0.5,
      all: true,
    }),
  );

  const filteredCommits = $derived(searchResults.map(r => r.obj.commit));

  const groupedCommits = $derived.by<CommitGroup[]>(() => {
    const groups = new Map<string, CommitGroup>();

    for (const commit of filteredCommits) {
      const timestamp = commit.committer.time * 1000;
      const key = dayKey(timestamp);
      const current = groups.get(key);

      if (current) {
        current.commits.push(commit);
      } else {
        groups.set(key, {
          key,
          label: dayLabel(timestamp),
          commits: [commit],
        });
      }
    }

    return [...groups.values()];
  });
</script>

<style>
  .page {
    display: flex;
    flex-direction: column;
    height: 100%;
    min-height: 0;
  }
  .topbar-title {
    font: var(--txt-body-m-semibold);
    color: var(--color-text-secondary);
    display: flex;
    align-items: center;
    gap: 0.25rem;
  }
  .branch-group {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    margin-left: 1.5rem;
  }
  .branch {
    color: var(--color-text-secondary);
    font: var(--txt-body-m-regular);
  }
  .canonical-badge {
    display: inline-flex;
    align-items: center;
    height: 1.25rem;
    padding: 0 0.375rem;
    border-radius: var(--border-radius-sm);
    background-color: var(--color-surface-strong);
    color: var(--color-text-secondary);
    font: var(--txt-body-s-regular);
    text-transform: lowercase;
    flex-shrink: 0;
  }
  .content {
    display: flex;
    flex-direction: column;
    flex: 1;
    padding: 1rem;
  }
  .group + .group {
    margin-top: 1.5rem;
  }
  .group-title {
    color: var(--color-text-secondary);
    font: var(--txt-body-m-regular);
    margin-bottom: 0.75rem;
  }
  .commit-list {
    display: flex;
    flex-direction: column;
    border: 1px solid var(--color-border-subtle);
    border-radius: var(--border-radius-md);
    overflow: hidden;
    background: var(--color-surface-canvas);
  }
  .commit-item {
    padding: 0.625rem 1rem;
    cursor: pointer;
  }
  .commit-item + .commit-item {
    border-top: 1px solid var(--color-border-subtle);
  }
  .commit-item:hover {
    background: var(--color-surface-subtle);
  }
</style>

<Layout>
  <div class="page">
    <Topbar>
      <span class="topbar-title">
        Commits
        <span class="branch-group">
          <Icon name="branch" />
          <span class="branch">{project.data.defaultBranch}</span>
          <span class="canonical-badge">canonical</span>
        </span>
        {#if commitCount !== undefined}
          <span class="global-counter-badge">{commitCount}</span>
        {/if}
      </span>
      <div class="global-flex" style:margin-left="auto" style:gap="0.5rem">
        <FuzzySearch
          hasItems={items.length > 0}
          placeholder={`Fuzzy filter commits ${modifierKey()} + f`}
          icon={loading ? "clock" : "filter"}
          onFocus={async () => {
            try {
              loading = true;
              await loadMoreContent(true);
            } catch (e) {
              console.error("Loading all commits failed: ", e);
            } finally {
              loading = false;
            }
          }}
          onSubmit={() => {
            if (filteredCommits.length === 1) {
              void router.push({
                resource: "repo.commit",
                rid: repo.rid,
                commit: filteredCommits[0].id,
              });
            }
          }}
          bind:show={showSearch}
          bind:value={searchInput} />
      </div>
    </Topbar>
    <div class="content">
      {#each groupedCommits as group (group.key)}
        <section class="group">
          <div class="group-title">{group.label}</div>
          <div class="commit-list">
            {#each group.commits as commit (commit.id)}
              <div
                class="commit-item"
                role="button"
                tabindex="0"
                onclick={() => {
                  void router.push({
                    resource: "repo.commit",
                    rid: repo.rid,
                    commit: commit.id,
                  });
                }}
                onkeydown={e => {
                  if (e.key === "Enter" || e.key === " ") {
                    e.preventDefault();
                    void router.push({
                      resource: "repo.commit",
                      rid: repo.rid,
                      commit: commit.id,
                    });
                  }
                }}>
                <CobCommitTeaser {commit} disabled={false} flush hoverable />
              </div>
            {/each}
          </div>
        </section>
      {/each}

      {#if filteredCommits.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}
              No matching commits
            {:else}
              No commits
            {/if}
          </div>
        </div>
      {/if}

      <InfiniteScrollSentinel
        onIntersect={loadMoreContent}
        disabled={!more || loadingMore} />
    </div>
  </div>
</Layout>