Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Show commits in patch activity timeline
Brandon Oxendine committed 1 month ago
commit 9be9f6fc62d2a38a93e89c00df60ceaee249108b
parent dd2582a0fa7cf773b9ec2db48f5df93d7dbd1dd1
5 files changed +254 -3
added src/components/CommitActivityItem.svelte
@@ -0,0 +1,64 @@
+
<script lang="ts">
+
  import type { Commit } from "@bindings/repo/Commit";
+

+
  import { absoluteTimestamp, formatTimestamp } from "@app/lib/utils";
+

+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+

+
  interface Props {
+
    commit: Commit;
+
  }
+

+
  const { commit }: Props = $props();
+
</script>
+

+
<style>
+
  .timeline-item {
+
    display: flex;
+
    align-items: flex-start;
+
    gap: 0.5rem;
+
    min-width: 0;
+
  }
+
  .wrapper {
+
    display: flex;
+
    flex-wrap: wrap;
+
    gap: 0.25rem;
+
    min-width: 0;
+
    flex: 1 1 0;
+
  }
+
  .icon {
+
    padding-top: 0.1875rem;
+
    color: var(--color-text-secondary);
+
  }
+
  .author {
+
    color: var(--color-text-primary);
+
  }
+
  .summary-line {
+
    flex: 1 1 0;
+
    min-width: 0;
+
    white-space: nowrap;
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
  }
+
  .timestamp {
+
    color: var(--color-text-quaternary);
+
  }
+
</style>
+

+
<div class="timeline-item txt-body-m-regular">
+
  <div class="icon">
+
    <Icon name="commit" />
+
  </div>
+
  <div class="wrapper">
+
    <span class="author">{commit.author.name}</span>
+
    <div class="summary-line">
+
      committed <Id id={commit.id} clipboard={commit.id} /> — {commit.summary}
+
    </div>
+
    <div
+
      class="timestamp"
+
      title={absoluteTimestamp(commit.committer.time * 1000)}>
+
      {formatTimestamp(commit.committer.time * 1000)}
+
    </div>
+
  </div>
+
</div>
added src/components/CommitGroupActivityItem.svelte
@@ -0,0 +1,82 @@
+
<script lang="ts">
+
  import type { Commit } from "@bindings/repo/Commit";
+

+
  import CommitActivityItem from "@app/components/CommitActivityItem.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+

+
  interface Props {
+
    commits: Commit[];
+
    expandBatch?: number;
+
  }
+

+
  const { commits, expandBatch = 10 }: Props = $props();
+

+
  let expanded = $state(0);
+

+
  const visible = $derived(commits.slice(0, expanded));
+
  const remaining = $derived(commits.length - expanded);
+
  const batchSize = $derived(Math.min(expandBatch, remaining));
+

+
  function showMore() {
+
    expanded = Math.min(expanded + expandBatch, commits.length);
+
  }
+
</script>
+

+
<style>
+
  .connector {
+
    width: 1px;
+
    height: 1rem;
+
    margin-left: 1.25rem;
+
    background-color: var(--color-border-subtle);
+
  }
+
  .collapsed {
+
    display: flex;
+
    align-items: flex-start;
+
    gap: 0.5rem;
+
  }
+
  .icon {
+
    padding-top: 0.1875rem;
+
    color: var(--color-text-secondary);
+
  }
+
  .wrapper {
+
    display: flex;
+
    align-items: center;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
  }
+
  .label {
+
    color: var(--color-text-tertiary);
+
  }
+
  .button {
+
    background: var(--color-surface-subtle);
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-sm);
+
    padding: 0.125rem 0.5rem;
+
    cursor: pointer;
+
    color: var(--color-text-secondary);
+
  }
+
  .button:hover {
+
    color: var(--color-text-primary);
+
  }
+
</style>
+

+
{#each visible as commit, idx (commit.id)}
+
  <CommitActivityItem {commit} />
+
  {#if idx < visible.length - 1 || remaining > 0}
+
    <div class="connector"></div>
+
  {/if}
+
{/each}
+

+
{#if remaining > 0}
+
  <div class="collapsed txt-body-m-regular">
+
    <div class="icon">
+
      <Icon name="commit" />
+
    </div>
+
    <div class="wrapper">
+
      <span class="label">{remaining} commits</span>
+
      <button class="button txt-body-s-regular" onclick={showMore}>
+
        Show {batchSize} more
+
      </button>
+
    </div>
+
  </div>
+
{/if}
modified src/components/PatchActivityItem.svelte
@@ -30,6 +30,12 @@

  const { op, patchId }: Props = $props();

+
  function lastLine(text: string): string | undefined {
+
    const lines = text.trim().split("\n");
+
    const last = lines[lines.length - 1]?.trim();
+
    return last && last.length > 0 ? last : undefined;
+
  }
+

  function itemDiff<A>(previousState: A[], newState: A[]) {
    const removed = previousState.filter(x => !newState.includes(x));
    const added = newState.filter(x => !previousState.includes(x));
@@ -103,6 +109,7 @@
      </div>
    </div>
  {:else}
+
    {@const summary = lastLine(op.description)}
    <div class="timeline-item txt-body-m-regular">
      <div class="icon">
        <Icon name="revision" />
@@ -111,6 +118,9 @@
        <NodeId {...authorForNodeId(op.author)} />
        <div class="summary-line">
          created revision <Id id={op.id} clipboard={op.id} />
+
          {#if summary}
+
            — {summary}
+
          {/if}
        </div>
        <div class="timestamp" title={absoluteTimestamp(op.timestamp)}>
          {formatTimestamp(op.timestamp)}
modified src/components/Revision.svelte
@@ -7,15 +7,18 @@
  import type { Embed } from "@bindings/cob/thread/Embed";
  import type { Thread } from "@bindings/cob/thread/Thread";
  import type { Config } from "@bindings/config/Config";
+
  import type { Commit } from "@bindings/repo/Commit";

  import { nodeRunning } from "@app/lib/events";
-
  import { invoke } from "@app/lib/invoke";
+
  import { cachedListCommits, invoke } from "@app/lib/invoke";
  import * as roles from "@app/lib/roles";
  import { publicKeyFromDid } from "@app/lib/utils";

  import { announce } from "@app/components/AnnounceSwitch.svelte";
  import Changes from "@app/components/Changes.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
+
  import CommitActivityItem from "@app/components/CommitActivityItem.svelte";
+
  import CommitGroupActivityItem from "@app/components/CommitGroupActivityItem.svelte";
  import Discussion, {
    type ActivityItem,
  } from "@app/components/Discussion.svelte";
@@ -26,8 +29,12 @@

  type ActivityData =
    | { kind: "op"; op: FlattenedPatchOperation }
+
    | { kind: "commit"; commit: Commit }
+
    | { kind: "commitGroup"; groupId: string; commits: Commit[] }
    | { kind: "reviewCode"; thread: Thread<CodeLocation> };

+
  const COMMIT_COLLAPSE_THRESHOLD = 5;
+

  interface Props {
    rid: string;
    repoDelegates: Author[];
@@ -37,6 +44,7 @@
    loadPatch: () => Promise<void>;
    view?: "description" | "activity" | "changes";
    activity?: Operation<Action>[];
+
    revisions?: Revision[];
  }

  const {
@@ -48,8 +56,38 @@
    loadPatch,
    view = "activity",
    activity = [],
+
    revisions = [],
  }: Props = $props();

+
  let commitsByRevision: Record<string, Commit[]> = $state({});
+

+
  $effect(() => {
+
    const ridLocal = rid;
+
    const revs = [...revisions].sort((a, b) => a.timestamp - b.timestamp);
+
    void Promise.all(
+
      revs.map(async (rev, idx): Promise<[string, Commit[]]> => {
+
        const prev = revs[idx - 1];
+
        const base = prev ? prev.head : rev.base;
+
        try {
+
          const commits = await cachedListCommits(ridLocal, base, rev.head);
+
          return [rev.id, commits];
+
        } catch (error) {
+
          console.error(
+
            `Failed to load commits for revision ${rev.id} (${base}..${rev.head})`,
+
            error,
+
          );
+
          return [rev.id, []];
+
        }
+
      }),
+
    ).then(entries => {
+
      const next: Record<string, Commit[]> = {};
+
      entries.forEach(([id, commits]) => {
+
        next[id] = commits;
+
      });
+
      commitsByRevision = next;
+
    });
+
  });
+

  const skippedActivityTypes = new Set<Action["type"]>([
    "revision.comment",
    "revision.comment.edit",
@@ -118,7 +156,59 @@
        });
    });

-
    return items;
+
    const sortedRevs = [...revisions].sort((a, b) => a.timestamp - b.timestamp);
+
    const patchOpenTimestamp = sortedRevs[0]?.timestamp ?? 0;
+
    Object.values(commitsByRevision).forEach(commits => {
+
      commits.forEach(commit => {
+
        const timestampMs = commit.committer.time * 1000;
+
        if (timestampMs < patchOpenTimestamp) {
+
          return;
+
        }
+
        items.push({
+
          key: `commit:${commit.id}`,
+
          timestamp: timestampMs,
+
          data: { kind: "commit", commit },
+
        });
+
      });
+
    });
+

+
    items.sort((a, b) => a.timestamp - b.timestamp);
+

+
    const grouped: ActivityItem<ActivityData>[] = [];
+
    let i = 0;
+
    while (i < items.length) {
+
      if (items[i].data.kind !== "commit") {
+
        grouped.push(items[i]);
+
        i++;
+
        continue;
+
      }
+
      let j = i;
+
      while (j < items.length && items[j].data.kind === "commit") {
+
        j++;
+
      }
+
      const runLength = j - i;
+
      if (runLength > COMMIT_COLLAPSE_THRESHOLD) {
+
        const commits = items.slice(i, j).map(item => {
+
          if (item.data.kind !== "commit") {
+
            throw new Error("unreachable");
+
          }
+
          return item.data.commit;
+
        });
+
        const groupId = `commit-group:${commits[0].id}:${commits[commits.length - 1].id}`;
+
        grouped.push({
+
          key: groupId,
+
          timestamp: items[i].timestamp,
+
          data: { kind: "commitGroup", groupId, commits },
+
        });
+
      } else {
+
        for (let k = i; k < j; k++) {
+
          grouped.push(items[k]);
+
        }
+
      }
+
      i = j;
+
    }
+

+
    return grouped;
  });
  const reviewSummaryFingerprints = $derived(
    new Set(
@@ -295,6 +385,10 @@
  {#snippet renderActivity(data: ActivityData)}
    {#if data.kind === "op"}
      <PatchActivityItem op={data.op} {patchId} />
+
    {:else if data.kind === "commit"}
+
      <CommitActivityItem commit={data.commit} />
+
    {:else if data.kind === "commitGroup"}
+
      <CommitGroupActivityItem commits={data.commits} />
    {:else}
      <ReviewCodeThread
        {rid}
modified src/views/repo/Patch.svelte
@@ -386,7 +386,8 @@
              revision={selectedRevision}
              {config}
              view={patchView}
-
              {activity} />
+
              {activity}
+
              {revisions} />
          </div>

          <div class="sidebar">