Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add commit detail view
Sebastian Martinez committed 4 years ago
commit 7ef1428284124d4c55c772539263121ffb96a5ba
parent 0cf6ef342744e39685105b5057d7f99d8454b0a7
13 files changed +702 -181
modified public/index.css
@@ -101,6 +101,7 @@ body {

html {
	height: 100%;
+
	overflow-y: scroll;
	-webkit-text-size-adjust: 100%;
	-ms-text-size-adjust: 100%;
	-ms-overflow-style: scrollbar;
@@ -354,6 +355,10 @@ label.input {
	align-items: center;
}

+
.font-mono {
+
  font-family: var(--font-family-monospace);
+
}
+

.mobile {
  display: none !important;
}
modified src/Icon.svelte
@@ -4,6 +4,7 @@
  export let height: number | null = null;
  export let inline = false;
  export let fill = false;
+
  export let clickHandler: (() => void) | undefined = undefined;

  const icons = [
    {
@@ -31,6 +32,17 @@
      size: 16,
      offset: { x: -1, y: 0 },
      data: `<circle cx="6.5" cy="13.5" r="2" stroke="#5555FF"/><circle cx="10.5" cy="2.5" r="2" stroke="#5555FF"/><circle cx="2.5" cy="2.5" r="2" stroke="#5555FF"/><path d="M6.5 11.5C6.5 7 2.5 8 2.5 5.5C2.5 3.9 2.5 4.66667 2.5 4" stroke="#5555FF"/><path d="M6.5 11.5C6.5 7 10.5 8 10.5 5.5C10.5 3.9 10.5 4.66667 10.5 4" stroke="#5555FF"/>`
+
    },
+
    {
+
      name: "browse",
+
      size: 16,
+
      data: `<path fill-rule="evenodd" d="M4.72 3.22a.75.75 0 011.06 1.06L2.06 8l3.72 3.72a.75.75 0 11-1.06 1.06L.47 8.53a.75.75 0 010-1.06l4.25-4.25zm6.56 0a.75.75 0 10-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 101.06 1.06l4.25-4.25a.75.75 0 000-1.06l-4.25-4.25z"></path>`
+
    },
+
    {
+
      name: "chevron",
+
      size: 15,
+
      offset: { x: 0, y: 1 },
+
      data: `<path fill-rule="evenodd" d="M12.78 6.22a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.22 7.28a.75.75 0 011.06-1.06L8 9.94l3.72-3.72a.75.75 0 011.06 0z"></path>`
    }
  ];
  const svg = icons.find(e => e.name === name);
@@ -43,13 +55,17 @@
  svg.inline {
    height: 1.6rem;
  }
+
  svg.clickable {
+
    cursor: pointer;
+
  }
</style>

{#if svg}
  <svg role="img" class={$$props.class} class:inline class:fill
       width={width || "1rem"}
       height={height || "1rem"}
-
       viewBox="{svg.offset?.x || 0} {svg.offset?.y || 0} {svg.size} {svg.size}">
+
       viewBox="{svg.offset?.x || 0} {svg.offset?.y || 0} {svg.size} {svg.size}"
+
       on:click|stopPropagation={clickHandler}>
    {@html svg.data}
  </svg>
{/if}
added src/base/projects/Commit.svelte
@@ -0,0 +1,96 @@
+
<script lang="ts">
+
  import * as proj from "@app/project";
+
  import Changeset from "@app/base/projects/SourceBrowser/Changeset.svelte";
+
  import { navigate } from "svelte-routing";
+
  import { formatCommitTime } from "@app/commit";
+
  import { formatCommit } from "@app/utils";
+

+
  export let content: proj.ProjectContent;
+
  export let revision: string;
+
  export let locator: string;
+
  export let source: any;
+

+
  const { org, user, peer, seed } = source;
+

+
  const navigateCommit = (path: string, content?: proj.ProjectContent) => {
+
    // Replaces path with current path if none passed.
+
    if (path === undefined) path = "/";
+

+
    if (org) {
+
      navigate(proj.path({ content, peer, urn, org, revision, path }));
+
    } else if (user) {
+
      navigate(proj.path({ content, peer, urn, user, revision, path }));
+
    } else if (seed) {
+
      navigate(proj.path({ content, peer, urn, seed, revision, path }));
+
    } else {
+
      navigate(proj.path({ content, peer, urn, revision, path }));
+
    }
+
  };
+

+
  let { project, urn, branches, config } = source;
+

+
  $: [revision_,] = proj.splitPrefixFromPath(locator, branches, project.head);
+
  $: content = proj.ProjectContent.Commit;
+
  $: revision = revision_;
+
</script>
+

+
<style>
+
  .commit {
+
    padding: 0 2rem 0 8rem;
+
    font-size: 0.875rem;
+
  }
+
  h3 {
+
    margin: 0;
+
  }
+
  header {
+
    padding: 1rem;
+
    background: var(--color-foreground-background-subtle);
+
    border-radius: 0.5rem;
+
  }
+
  .summary {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: space-between;
+
    align-items: flex-start;
+
  }
+
  @media (max-width: 960px) {
+
    .commit {
+
      padding-left: 2rem;
+
    }
+
  }
+
</style>
+

+
{#await proj.getCommit(urn, revision, config) then commit}
+
  <div class="commit">
+
    <header>
+
      <div class="summary">
+
        <h3>{commit.header.summary}</h3>
+
        <div class="desktop font-mono faded">
+
          <span>commit</span>
+
          <span>{commit.header.sha1}</span>
+
        </div>
+
        <div class="mobile font-mono faded">
+
          {formatCommit(commit.header.sha1)}
+
        </div>
+
      </div>
+
      <pre>{commit.header.description}</pre>
+
      <div>
+
        <span>Committed by {commit.header.committer.name}</span>
+
        <span class="font-mono faded desktop-inline">&lt;{commit.header.committer.email}&gt; </span>
+
        <span class="desktop-inline">{formatCommitTime(commit.header.committerTime)}</span>
+
      </div>
+
      <div>
+
        <span>Authored by {commit.header.author.name} </span>
+
        <span class="font-mono faded desktop-inline">&lt;{commit.header.author.email}&gt;</span>
+
      </div>
+
    </header>
+
    <Changeset stats={commit.stats} diff={commit.diff} on:browse={(event) => navigateCommit(event.detail)} />
+
  </div>
+
{:catch err}
+
  <div class="commit">
+
    <div class="error error-message text-xsmall">
+
      <div>API request to <code class="text-xsmall">{err.url}</code> failed.</div>
+
      <div>API needs to be version ^0.2.</div>
+
    </div>
+
  </div>
+
{/await}
modified src/base/projects/Commit/CommitTeaser.svelte
@@ -1,9 +1,17 @@
<script lang="ts">
-
  import type { CommitHeader } from "@app/project";
+
  import Icon from "@app/Icon.svelte";
+
  import type { CommitHeader } from "@app/commit";
  import { formatCommit } from "@app/utils";
-
  import { formatCommitTime } from "./lib";
+
  import { createEventDispatcher } from "svelte";
+
  import { formatCommitTime } from "@app/commit";

  export let commit: CommitHeader;
+

+
  const dispatch = createEventDispatcher();
+

+
  function browseCommit(commit: string) {
+
    dispatch("browseCommit", commit);
+
  }
</script>

<style>
@@ -20,6 +28,15 @@
    justify-content: space-between;
    padding: 0 1rem 0 1rem;
    height: 2.5rem;
+
    cursor: pointer;
+
  }
+
  .commit .right {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
  }
+
  .time {
+
    margin-right: 0.5rem;
  }

  @media (max-width: 720px) {
@@ -48,8 +65,9 @@
    <span class="secondary desktop-inline hash">{formatCommit(commit.sha1)}</span>
    <span>{commit.summary}</span>
  </div>
-
  <div>
+
  <div class="right">
    <span class="bold author">{commit.committer.name}</span>
-
    <span class="desktop-inline">{formatCommitTime(commit.committerTime)}</span>
+
    <span class="desktop-inline time">{formatCommitTime(commit.committerTime)}</span>
+
    <Icon name="browse" clickHandler={() => browseCommit(commit.sha1)} width={17} inline fill />
  </div>
</div>
deleted src/base/projects/Commit/History.svelte
@@ -1,74 +0,0 @@
-
<script lang="ts">
-
  import CommitTeaser from "./CommitTeaser.svelte";
-
  import { getCommits, Source, getOid, ProjectContent, splitPrefixFromPath } from "@app/project";
-
  import Loading from "@app/Loading.svelte";
-
  import { groupCommitHistory, GroupedCommitsHistory } from "./lib";
-

-
  export let source: Source;
-
  export let locator: string;
-
  export let content: ProjectContent;
-
  export let revision: string;
-

-
  let { urn, config, project, branches } = source;
-

-
  // Bind content to commit history to trigger updates in parent components.
-
  $: [revision_,] = splitPrefixFromPath(locator, branches, project.head);
-
  $: content = ProjectContent.History;
-
  $: revision = revision_;
-

-
  async function fetchCommits(revision: string): Promise<GroupedCommitsHistory> {
-
    const commitsQuery = await getCommits(urn, getOid(project.head, revision, branches), config);
-
    return groupCommitHistory(commitsQuery);
-
  }
-
</script>
-

-
<style>
-
  .history {
-
    padding: 0 2rem 0 8rem;
-
    font-size: 0.875rem;
-
  }
-
  .commit-group header {
-
    color: var(--color-foreground-faded);
-
  }
-
  .commit-group-headers {
-
    border-radius: 0.25rem;
-
    margin-bottom: 2rem;
-
    background: var(--color-foreground-background);
-
  }
-
  .commit {
-
    padding: 0.25rem 0;
-
  }
-
  @media (max-width: 960px) {
-
    .history {
-
      padding-left: 2rem;
-
    }
-
  }
-
</style>
-

-
{#await fetchCommits(revision)}
-
  <Loading center />
-
{:then history}
-
  <div class="history">
-
    {#each history.headers as group (group.time)}
-
      <div class="commit-group">
-
        <header>
-
          <p>{group.time}</p>
-
        </header>
-
        <div class="commit-group-headers">
-
          {#each group.commits as commit (commit.sha1)}
-
            <div class="commit">
-
              <CommitTeaser {commit} />
-
            </div>
-
          {/each}
-
        </div>
-
      </div>
-
    {/each}
-
  </div>
-
{:catch err}
-
  <div class="history">
-
    <div class="error error-message text-xsmall">
-
      <div>API request to <code class="text-xsmall">{err.url}</code> failed.</div>
-
      <div>API needs to be version ^0.2.</div>
-
    </div>
-
  </div>
-
{/await}
deleted src/base/projects/Commit/lib.ts
@@ -1,83 +0,0 @@
-
import type { CommitHeader, Stats } from "@app/project";
-

-
export interface CommitsHistory {
-
  headers: CommitHeader[];
-
  stats: Stats;
-
}
-
export interface GroupedCommitsHistory {
-
  headers: CommitGroup[];
-
  stats: Stats;
-
}
-

-
export interface CommitStats {
-
  branches: number;
-
  commits: number;
-
  contributors: number;
-
}
-

-
export interface GroupedCommitsHistory {
-
  headers: CommitGroup[];
-
  stats: Stats;
-
}
-

-
// A set of commits grouped by time.
-
export interface CommitGroup {
-
  time: string;
-
  commits: CommitHeader[];
-
}
-

-

-
export function formatGroupTime(timestamp: number): string {
-
  return new Date(timestamp).toLocaleDateString("en-US", {
-
    day: 'numeric',
-
    weekday: 'long',
-
    month: 'long',
-
    year: 'numeric'
-
  });
-
}
-

-
export const groupCommitHistory = (
-
  history: CommitsHistory
-
): GroupedCommitsHistory => {
-
  return { ...history, headers: groupCommits(history.headers) };
-
};
-

-
export function groupCommits(commits: CommitHeader[]): CommitGroup[] {
-
  const groupedCommits: CommitGroup[] = [];
-
  let groupDate: Date | undefined = undefined;
-

-
  commits = commits.sort((a, b) => {
-
    if (a.committerTime > b.committerTime) {
-
      return -1;
-
    } else if (a.committerTime < b.committerTime) {
-
      return 1;
-
    }
-

-
    return 0;
-
  });
-

-
  for (const commit of commits) {
-
    const time = commit.committerTime * 1000;
-
    const date = new Date(time);
-
    const isNewDay =
-
      !groupedCommits.length ||
-
      !groupDate ||
-
      date.getDate() < groupDate.getDate() ||
-
      date.getMonth() < groupDate.getMonth() ||
-
      date.getFullYear() < groupDate.getFullYear();
-

-
    if (isNewDay) {
-
      groupedCommits.push({
-
        time: formatGroupTime(time),
-
        commits: [],
-
      });
-
      groupDate = date;
-
    }
-
    groupedCommits[groupedCommits.length - 1].commits.push(commit);
-
  }
-
  return groupedCommits;
-
}
-

-
export const formatCommitTime = (t: number): string => {
-
  return new Date(t * 1000).toUTCString();
-
};
added src/base/projects/History.svelte
@@ -0,0 +1,92 @@
+
<script lang="ts">
+
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
+
  import { getCommits, Source, getOid, ProjectContent, splitPrefixFromPath } from "@app/project";
+
  import * as proj from "@app/project";
+
  import Loading from "@app/Loading.svelte";
+
  import { groupCommitHistory, GroupedCommitsHistory } from "@app/commit";
+
  import { navigate } from "svelte-routing";
+

+
  export let source: Source;
+
  export let locator: string;
+
  export let content: ProjectContent;
+
  export let revision: string;
+
  export let path: string;
+

+
  let { urn, user, seed, org, peer, config, project, branches } = source;
+

+
  // Bind content to commit history to trigger updates in parent components.
+
  $: [revision_,] = splitPrefixFromPath(locator, branches, project.head);
+
  $: content = content ?? ProjectContent.History;
+
  $: revision = revision_;
+

+
  const navigateHistory = (revision: string, content?: ProjectContent) => {
+
    // Replaces path with current path if none passed.
+
    if (path === undefined) path = "/";
+

+
    if (org) {
+
      navigate(proj.path({ content, peer, urn, org, revision, path }));
+
    } else if (user) {
+
      navigate(proj.path({ content, peer, urn, user, revision, path }));
+
    } else if (seed) {
+
      navigate(proj.path({ content, peer, urn, seed, revision, path }));
+
    } else {
+
      navigate(proj.path({ content, peer, urn, revision, path }));
+
    }
+
  };
+

+
  async function fetchCommits(revision: string): Promise<GroupedCommitsHistory> {
+
    const commitsQuery = await getCommits(urn, getOid(project.head, revision, branches), config);
+
    return groupCommitHistory(commitsQuery);
+
  }
+
</script>
+

+
<style>
+
  .history {
+
    padding: 0 2rem 0 8rem;
+
    font-size: 0.875rem;
+
  }
+
  .commit-group header {
+
    color: var(--color-foreground-faded);
+
  }
+
  .commit-group-headers {
+
    border-radius: 0.25rem;
+
    margin-bottom: 2rem;
+
    background: var(--color-foreground-background);
+
  }
+
  .commit {
+
    padding: 0.25rem 0;
+
  }
+
  @media (max-width: 960px) {
+
    .history {
+
      padding-left: 2rem;
+
    }
+
  }
+
</style>
+

+
{#await fetchCommits(revision)}
+
  <Loading center />
+
{:then history}
+
  <div class="history">
+
    {#each history.headers as group (group.time)}
+
      <div class="commit-group">
+
        <header>
+
          <p>{group.time}</p>
+
        </header>
+
        <div class="commit-group-headers">
+
          {#each group.commits as commit (commit.sha1)}
+
            <div class="commit" on:click={() => navigateHistory(commit.sha1, ProjectContent.Commit)}>
+
              <CommitTeaser {commit} on:browseCommit={(event) => navigateHistory(event.detail)} />
+
            </div>
+
          {/each}
+
        </div>
+
      </div>
+
    {/each}
+
  </div>
+
{:catch err}
+
  <div class="history">
+
    <div class="error error-message text-xsmall">
+
      <div>API request to <code class="text-xsmall">{err.url}</code> failed.</div>
+
      <div>API needs to be version ^0.2.</div>
+
    </div>
+
  </div>
+
{/await}
modified src/base/projects/ProjectContentRoutes.svelte
@@ -2,7 +2,8 @@
  import type { ProjectContent, Source, Tree } from "@app/project";
  import { Route, Router } from "svelte-routing";
  import Browser from "./Browser.svelte";
-
  import History from "./Commit/History.svelte";
+
  import Commit from "./Commit.svelte";
+
  import History from "./History.svelte";

  export let source: Source;
  export let tree: Tree;
@@ -34,12 +35,22 @@
      bind:revision={revision} />
  </Route>
  <Route path="/history">
-
    <History {locator} {source}
+
    <History {locator} {source} {path}
      bind:content={content}
      bind:revision={revision} />
  </Route>
  <Route path="/history/*" let:params>
-
    <History locator={params["*"]} {source}
+
    <History locator={params["*"]} {source} {path}
+
      bind:content={content}
+
      bind:revision={revision} />
+
  </Route>
+
  <Route path="/commit/:commit" let:params>
+
    <Commit {source} locator={params.commit}
+
      bind:content={content}
+
      bind:revision={revision} />
+
  </Route>
+
  <Route path="/commit/*" let:params>
+
    <Commit {source} locator={params["*"]}
      bind:content={content}
      bind:revision={revision} />
  </Route>
added src/base/projects/SourceBrowser/Changeset.svelte
@@ -0,0 +1,92 @@
+
<script lang="ts">
+
  import { createEventDispatcher } from "svelte";
+
  import type { CommitStats } from "@app/commit";
+
  import type { Diff } from "@app/diff";
+
  import Icon from "@app/Icon.svelte";
+
  import FileDiff from "@app/base/projects/SourceBrowser/FileDiff.svelte";
+

+
  const dispatch = createEventDispatcher();
+

+
  export let diff: Diff;
+
  export let stats: CommitStats;
+
</script>
+

+
<style>
+
  .changeset-summary {
+
    margin: 1.5rem 0;
+
    margin-left: 1rem;
+
  }
+
  .changeset-summary .additions {
+
    color: var(--color-positive);
+
  }
+
  .changeset-summary .deletions {
+
    color: var(--color-negative);
+
  }
+
  .file-header {
+
    border: 1px solid var(--color-foreground-3);
+
    border-radius: 0.5rem;
+
    height: 3rem;
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: space-between;
+
    align-items: center;
+
    background: none;
+
    padding: 1rem;
+
    margin-bottom: 1rem;
+
  }
+
  .file-header:last-child {
+
    margin-bottom: 1rem;
+
  }
+
  .file-header .diff-type {
+
    margin-left: 1rem;
+
    padding: 0.25rem 0.5rem;
+
    border-radius: 0.25rem;
+
  }
+
  .file-header .diff-type.created {
+
    color: var(--color-positive);
+
    background-color: var(--color-positive-1);
+
  }
+
  .file-header .diff-type.deleted {
+
    color: var(--color-negative);
+
    background-color: var(--color-negative-1);
+
  }
+
  .file-header .file-data {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
  }
+
</style>
+

+
<div class="changeset-summary">
+
  {#if diff.modified.length > 0}
+
    <span class="bold"> {diff.modified.length} file(s) changed </span>
+
    with
+
    <span class="additions bold"> {stats.additions} additions </span>
+
    and
+
    <span class="deletions bold"> {stats.deletions} deletions </span>
+
  {/if}
+
</div>
+
<div>
+
  {#each diff.created as path (path)}
+
    <header id={path} class="file-header">
+
      <div class="file-data">
+
        <p class="bold">{path}</p>
+
        <span class="diff-type created">created</span>
+
      </div>
+
      <Icon class="clickable" clickHandler={() => dispatch("browse", path)} name="browse" width={20} inline fill />
+
    </header>
+
  {/each}
+
  {#each diff.deleted as path (path)}
+
    <header id={path} class="file-header">
+
      <div class="file-data">
+
        <p class="bold">{path}</p>
+
        <span class="diff-type deleted">deleted</span>
+
      </div>
+
      <Icon class="clickable" clickHandler={() => dispatch("browse", path)} name="browse" width={20} inline fill />
+
    </header>
+
  {/each}
+
</div>
+

+
{#each diff.modified as file}
+
  <FileDiff on:browse {file} />
+
{/each}
added src/base/projects/SourceBrowser/FileDiff.svelte
@@ -0,0 +1,153 @@
+
<script lang="ts">
+
  import { createEventDispatcher } from "svelte";
+
  import Icon from "@app/Icon.svelte";
+
  import { lineNumberL, lineNumberR, lineSign } from "@app/diff";
+
  import type { FileDiff } from "@app/diff";
+
  
+
  const dispatch = createEventDispatcher();
+

+
  export let file: FileDiff;
+

+
  function collapse() {
+
    collapsed = !collapsed;
+
  }
+

+
  let collapsed = false;
+
</script>
+

+
<style>
+
  .changeset-file {
+
    border: 1px solid var(--color-foreground-3);
+
    border-radius: 0.5rem;
+
    min-width: var(--content-min-width);
+
    margin-bottom: 2rem;
+
  }
+
  .changeset-file header {
+
    cursor: pointer;
+
    height: 3rem;
+
    display: flex;
+
    align-items: center;
+
    background: none;
+
    border-radius: 0;
+
    padding: 1rem;
+
  }
+
  main {
+
    border-top: 1px solid var(--color-foreground-3);
+
  }
+
  .changeset-file main {
+
    overflow-x: auto;
+
  }
+
  header {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: space-between;
+
  }
+
  header div.actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
  }
+
  .binary {
+
    padding: 1rem;
+
    color: var(--color-foreground-level-4);
+
    text-align: center;
+
    background-color: var(--color-foreground-1);
+
  }
+
  table.diff {
+
    font-family: var(--font-family-monospace);
+
    table-layout: fixed;
+
    border-collapse: collapse;
+
    margin: 0.5rem 0;
+
  }
+
  tr.diff-line[data-type="+"] > * {
+
    background: var(--color-positive-1);
+
  }
+
  tr.diff-line[data-type="-"] > * {
+
    background: var(--color-negative-1);
+
  }
+
  td.diff-line-number {
+
    text-align: right;
+
    user-select: none;
+
    line-height: 150%;
+
  }
+
  td.diff-line-number[data-type="+"],
+
  td.diff-line-type[data-type="+"] {
+
    color: var(--color-positive-6);
+
  }
+
  td.diff-line-number[data-type="-"],
+
  td.diff-line-type[data-type="-"] {
+
    color: var(--color-negative-6);
+
  }
+
  td.diff-line-number.left {
+
    padding: 0 0.25rem 0 1rem;
+
  }
+
  td.diff-line-number.right {
+
    padding: 0 1rem 0 0.25rem;
+
  }
+
  td.diff-line-content {
+
    white-space: pre;
+
    width: 100%;
+
    padding-right: 0.5rem;
+
  }
+
  td.diff-line-type {
+
    padding-right: 1rem;
+
    text-align: center;
+
  }
+
  td.diff-expand-header {
+
    background: var(--color-background);
+
    color: var(--color-foreground-4);
+
  }
+
  td.diff-line-number {
+
    color: var(--color-foreground-4);
+
  }
+
  .file-path {
+
    margin-left: 0.5rem;
+
  }
+
</style>
+

+
<article id={file.path} class="changeset-file">
+
  <header
+
    on:click={collapse}>
+
    <div class="actions">
+
      <Icon clickHandler={collapse} name="chevron" width={20} inline fill />
+
      <p class="bold file-path">{file.path}</p>
+
    </div>
+
    <Icon clickHandler={() => dispatch("browse", file.path)} name="browse" width={20} inline fill />
+
  </header>
+
  {#if !collapsed}
+
    <main>
+
      {#if file.diff.type === "plain" && file.diff.hunks.length > 0}
+
        <table class="diff">
+
          {#each file.diff.hunks as hunk}
+
            <tr class="diff-line">
+
              <td colspan={2} />
+
              <td colspan={6} class="diff-expand-header">
+
                {hunk.header}
+
              </td>
+
            </tr>
+
            {#each hunk.lines as line}
+
              <tr class="diff-line" data-expanded data-type={lineSign(line)}>
+
                <td
+
                  class="diff-line-number left"
+
                  data-type={lineSign(line)}>
+
                  {lineNumberL(line)}
+
                </td>
+
                <td
+
                  class="diff-line-number right"
+
                  data-type={lineSign(line)}>
+
                  {lineNumberR(line)}
+
                </td>
+
                <td class="diff-line-type" data-type={line.type}>
+
                  {lineSign(line)}
+
                </td>
+
                <td class="diff-line-content">{line.line}</td>
+
              </tr>
+
            {/each}
+
          {/each}
+
        </table>
+
      {:else}
+
        <div class="binary">Binary file</div>
+
      {/if}
+
    </main>
+
  {/if}
+
</article>
added src/commit.ts
@@ -0,0 +1,110 @@
+
import type { Stats } from "@app/project";
+
import type { Diff } from "@app/diff";
+

+
export interface CommitsHistory {
+
  headers: CommitHeader[];
+
  stats: Stats;
+
}
+
export interface GroupedCommitsHistory {
+
  headers: CommitGroup[];
+
  stats: Stats;
+
}
+

+
export interface Author {
+
  avatar: string;
+
  email: string;
+
  name: string;
+
}
+

+
export interface CommitStats {
+
  branches: number;
+
  commits: number;
+
  contributors: number;
+
}
+

+
export interface GroupedCommitsHistory {
+
  headers: CommitGroup[];
+
  stats: Stats;
+
}
+

+
export interface CommitHeader {
+
  author: Author;
+
  committer: Author;
+
  committerTime: number;
+
  description: string;
+
  sha1: string;
+
  summary: string;
+
}
+

+
// A set of commits grouped by time.
+
export interface CommitGroup {
+
  time: string;
+
  commits: CommitHeader[];
+
}
+

+
export interface CommitStats {
+
  additions: number;
+
  deletions: number;
+
}
+

+
export interface Commit {
+
  header: CommitHeader;
+
  stats: CommitStats;
+
  diff: Diff;
+
  branches: string[];
+
}
+

+
export function formatGroupTime(timestamp: number): string {
+
  return new Date(timestamp).toLocaleDateString("en-US", {
+
    day: 'numeric',
+
    weekday: 'long',
+
    month: 'long',
+
    year: 'numeric'
+
  });
+
}
+

+
export const groupCommitHistory = (
+
  history: CommitsHistory
+
): GroupedCommitsHistory => {
+
  return { ...history, headers: groupCommits(history.headers) };
+
};
+

+
export function groupCommits(commits: CommitHeader[]): CommitGroup[] {
+
  const groupedCommits: CommitGroup[] = [];
+
  let groupDate: Date | undefined = undefined;
+

+
  commits = commits.sort((a, b) => {
+
    if (a.committerTime > b.committerTime) {
+
      return -1;
+
    } else if (a.committerTime < b.committerTime) {
+
      return 1;
+
    }
+

+
    return 0;
+
  });
+

+
  for (const commit of commits) {
+
    const time = commit.committerTime * 1000;
+
    const date = new Date(time);
+
    const isNewDay =
+
      !groupedCommits.length ||
+
      !groupDate ||
+
      date.getDate() < groupDate.getDate() ||
+
      date.getMonth() < groupDate.getMonth() ||
+
      date.getFullYear() < groupDate.getFullYear();
+

+
    if (isNewDay) {
+
      groupedCommits.push({
+
        time: formatGroupTime(time),
+
        commits: [],
+
      });
+
      groupDate = date;
+
    }
+
    groupedCommits[groupedCommits.length - 1].commits.push(commit);
+
  }
+
  return groupedCommits;
+
}
+

+
export const formatCommitTime = (t: number): string => {
+
  return new Date(t * 1000).toUTCString();
+
};
added src/diff.ts
@@ -0,0 +1,91 @@
+
export const lineNumberR = (line: LineDiff): string | number => {
+
  switch (line.type) {
+
    case LineDiffType.Addition: {
+
      return line.lineNum;
+
    }
+
    case LineDiffType.Context: {
+
      return line.lineNumNew;
+
    }
+
    case LineDiffType.Deletion: {
+
      return " ";
+
    }
+
  }
+
};
+

+
export const lineNumberL = (line: LineDiff): string | number => {
+
  switch (line.type) {
+
    case LineDiffType.Addition: {
+
      return " ";
+
    }
+
    case LineDiffType.Context: {
+
      return line.lineNumOld;
+
    }
+
    case LineDiffType.Deletion: {
+
      return line.lineNum;
+
    }
+
  }
+
};
+

+
export const lineSign = (line: LineDiff): string => {
+
  switch (line.type) {
+
    case LineDiffType.Addition: {
+
      return "+";
+
    }
+
    case LineDiffType.Context: {
+
      return " ";
+
    }
+
    case LineDiffType.Deletion: {
+
      return "-";
+
    }
+
  }
+
};
+

+
export enum LineDiffType {
+
  Addition = "addition",
+
  Context = "context",
+
  Deletion = "deletion",
+
}
+

+
export interface Addition {
+
  type: LineDiffType.Addition;
+
  line: string;
+
  lineNum: number;
+
}
+

+
export interface Context {
+
  type: LineDiffType.Context;
+
  line: string;
+
  lineNumNew: number;
+
  lineNumOld: number;
+
}
+

+
export interface Deletion {
+
  type: LineDiffType.Deletion;
+
  line: string;
+
  lineNum: number;
+
}
+

+
export type LineDiff = Addition | Deletion | Context;
+

+
export interface FileDiff {
+
  path: string;
+
  diff: Changeset;
+
}
+

+
export interface Changeset {
+
  type: string;
+
  hunks: Hunk[];
+
}
+

+
export interface Hunk {
+
  header: string;
+
  lines: LineDiff[];
+
}
+

+
export interface Diff {
+
  created: string[];
+
  deleted: string[];
+
  moved: string[];
+
  copied: string[];
+
  modified: FileDiff[];
+
}
modified src/project.ts
@@ -1,6 +1,6 @@
import type { Config } from '@app/config';
import * as api from '@app/api';
-
import type { CommitsHistory } from '@app/base/projects/Commit/lib';
+
import type { Commit, CommitHeader, CommitsHistory } from '@app/commit';
import { isOid } from '@app/utils';
import type { Profile } from '@app/profile';

@@ -43,6 +43,7 @@ export interface PendingProject extends Project {
export enum ProjectContent {
  Tree,
  History,
+
  Commit,
}

export interface Info {
@@ -70,26 +71,11 @@ export interface Stats {
  contributors: number;
}

-
export interface Author {
-
  avatar: string;
-
  email: string;
-
  name: string;
-
}
-

export enum ObjectType {
  Blob = "BLOB",
  Tree = "TREE",
}

-
export interface CommitHeader {
-
  author: Author;
-
  committer: Author;
-
  committerTime: number;
-
  description: string;
-
  sha1: string;
-
  summary: string;
-
}
-

export interface EntryInfo {
  name: string;
  objectType: ObjectType;
@@ -118,6 +104,10 @@ export async function getInfo(urn: string, config: Config): Promise<Info> {
}

export async function getCommits(urn: string, commit: string, config: Config): Promise<CommitsHistory> {
+
  return api.get(`projects/${urn}/commits?from=${commit}`, {}, config);
+
}
+

+
export async function getCommit(urn: string, commit: string, config: Config): Promise<Commit> {
  return api.get(`projects/${urn}/commits/${commit}`, {}, config);
}

@@ -194,6 +184,10 @@ export function path(
      result.push("history");
      break;

+
    case ProjectContent.Commit:
+
      result.push("commit");
+
      break;
+

    default:
      result.push("tree");
      break;