Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Display images in source browser and diffs, enable preview for svgs
Merged did:key:z6MkkfM3...sVz5 opened 2 years ago
12 files changed +148 -61 481e25dc eb0013ef
modified public/colors.css
@@ -31,7 +31,7 @@
  --color-fill-secondary-hover: #8585ff;
  --color-fill-secondary-counter: #7a7aff;
  --color-fill-ghost: #ebebff;
-
  --color-fill-ghost-hover: #f5f5ff;
+
  --color-fill-ghost-hover: #dbdbff;
  --color-fill-separator: #dbdbff;
  --color-fill-primary: #ff70ff;
  --color-fill-primary-hover: #ff80ff;
@@ -45,7 +45,7 @@
  --color-fill-diff-green: #badeca;
  --color-fill-diff-green-light: #dcefe5;
  --color-fill-float: #fafaff;
-
  --color-fill-float-hover: #ffffff;
+
  --color-fill-float-hover: #dbdbff;
  --color-fill-merged: #ffeeff;
  --color-fill-selected: #ebebff;
  --color-fill-warning: #ffffe5;
modified public/index.css
@@ -91,6 +91,11 @@ pre {
  min-width: 1.25rem;
  text-align: center;
}
+
.global-spacer {
+
  width: 1px;
+
  height: 100%;
+
  background-color: var(--color-fill-ghost);
+
}

@media (max-width: 720px) {
  body {
modified src/App/Settings.svelte
@@ -46,6 +46,7 @@
          on:click={() => storeTheme("light")}>
          <Icon name="sun" />
        </Button>
+
        <div class="global-spacer" />
        <Button
          ariaLabel="Dark Mode"
          styleBorderRadius="0"
@@ -70,6 +71,7 @@
              : "not-selected"}>
            {font.displayName}
          </Button>
+
          <div class="global-spacer" />
        {/each}
      </Radio>
    </div>
modified src/components/Button.svelte
@@ -89,21 +89,6 @@
    background-color: var(--color-fill-ghost);
  }

-
  .not-selected {
-
    background-color: var(--color-fill-float-hover);
-
    color: var(--color-foreground-contrast);
-
    font-weight: var(--font-weight-normal);
-
  }
-
  .not-selected[disabled] {
-
    background-color: var(--color-fill-float-hover);
-
    color: var(--color-foreground-disabled);
-
    font-weight: var(--font-weight-normal);
-
  }
-
  .not-selected:not([disabled]):hover {
-
    background-color: var(--color-fill-ghost-hover);
-
    color: var(--color-foreground-contrast);
-
  }
-

  .gray {
    background-color: var(--color-fill-ghost);
    color: var(--color-foreground-contrast);
@@ -130,7 +115,7 @@
    color: var(--color-foreground-contrast);
  }
  .selected {
-
    background-color: var(--color-fill-ghost);
+
    background-color: var(--color-fill-float-hover);
    color: var(--color-foreground-contrast);
    cursor: default;
  }
@@ -139,6 +124,22 @@
    color: var(--color-foreground-disabled);
  }

+
  .not-selected {
+
    background-color: var(--color-fill-ghost);
+
    color: var(--color-foreground-contrast);
+
    font-weight: var(--font-weight-normal);
+
    letter-spacing: 0.02rem;
+
  }
+
  .not-selected[disabled] {
+
    background-color: var(--color-fill-float-hover);
+
    color: var(--color-foreground-disabled);
+
    font-weight: var(--font-weight-normal);
+
  }
+
  .not-selected:not([disabled]):hover {
+
    background-color: var(--color-fill-ghost-hover);
+
    color: var(--color-foreground-contrast);
+
  }
+

  .none {
    background-color: transparent;
    color: var(--color-foreground-emphasized);
modified src/components/ExtendedTextarea.svelte
@@ -179,6 +179,7 @@
      <IconSmall name="edit" />
      Edit
    </Button>
+
    <div class="global-spacer" />
    <Button
      styleBorderRadius="0"
      disabled={disallowEmptyBody && body.length === 0}
modified src/components/File.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
  import { tick } from "svelte";
+

  import ExpandButton from "./ExpandButton.svelte";

  export let collapsable: boolean = false;
@@ -74,7 +75,7 @@
  </div>

  <div class="right">
-
    <slot name="right-header" />
+
    <slot name="right-header" {expanded} />
  </div>
</div>

modified src/lib/utils.ts
@@ -192,6 +192,16 @@ export function isCommit(input: string): boolean {
  return /^[a-f0-9]{40}$/.test(input);
}

+
// Check whether the given path has a valid image file extension.
+
export function isImagePath(input: string): boolean {
+
  return /\.(jpg|jpeg|png|gif|bmp)$/i.test(input);
+
}
+

+
// Check whether the given path has a valid svg file extension.
+
export function isSvgPath(input: string): boolean {
+
  return /\.svg$/i.test(input);
+
}
+

export function isFulfilled<T>(
  input: PromiseSettledResult<T>,
): input is PromiseFulfilledResult<T> {
modified src/views/projects/Changeset/FileDiff.svelte
@@ -5,6 +5,7 @@
  import { toHtml } from "hast-util-to-html";

  import * as Syntax from "@app/lib/syntax";
+
  import { isImagePath, isSvgPath } from "@app/lib/utils";

  import Badge from "@app/components/Badge.svelte";
  import File from "@app/components/File.svelte";
@@ -14,6 +15,8 @@
  import Link from "@app/components/Link.svelte";
  import Loading from "@app/components/Loading.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
+
  import Radio from "@app/components/Radio.svelte";
+
  import Button from "@app/components/Button.svelte";

  export let filePath: string;
  export let oldContent: string | undefined = undefined;
@@ -35,6 +38,8 @@
  let selection: Selection | undefined = undefined;
  let highlighting: { new?: string[]; old?: string[] } | undefined = undefined;
  let syntaxHighlightingLoading: boolean = false;
+
  let preview = false;
+
  $: extension = filePath.split(".").pop();

  onMount(() => {
    window.addEventListener("click", deselectHandler);
@@ -400,29 +405,52 @@
    {/if}
  </svelte:fragment>

-
  <svelte:fragment slot="right-header">
+
  <svelte:fragment slot="right-header" let:expanded>
    {#if revision}
      {#if syntaxHighlightingLoading}
        <Loading small />
      {/if}
-
      <Link
-
        route={{
-
          resource: "project.source",
-
          project: projectId,
-
          node: baseUrl,
-
          path: filePath,
-
          revision,
-
        }}>
-
        <IconButton title="View file">
-
          <IconSmall name="chevron-left-right" />
-
        </IconButton>
-
      </Link>
+
      <div style:display="flex" style:align-items="center" style:gap="0.5rem">
+
        {#if isSvgPath(filePath) && expanded}
+
          <Radio ariaLabel="Toggle render method">
+
            <Button
+
              styleBorderRadius="0"
+
              variant={!preview ? "selected" : "not-selected"}
+
              on:click={() => {
+
                preview = false;
+
              }}>
+
              <IconSmall name="chevron-left-right" />Code
+
            </Button>
+
            <Button
+
              styleBorderRadius="0"
+
              variant={preview ? "selected" : "not-selected"}
+
              on:click={() => {
+
                window.location.hash = "";
+
                preview = true;
+
              }}>
+
              <IconSmall name="eye-open" />Preview
+
            </Button>
+
          </Radio>
+
        {/if}
+
        <Link
+
          route={{
+
            resource: "project.source",
+
            project: projectId,
+
            node: baseUrl,
+
            path: filePath,
+
            revision,
+
          }}>
+
          <IconButton title="View file">
+
            <IconSmall name="chevron-left-right" />
+
          </IconButton>
+
        </Link>
+
      </div>
    {/if}
  </svelte:fragment>

  <div class="container">
    {#if fileDiff.type === "plain"}
-
      {#if fileDiff.hunks.length > 0}
+
      {#if fileDiff.hunks.length > 0 && !preview}
        <table class="diff" data-file-diff-select>
          {#each fileDiff.hunks as hunk, hunkIdx}
            <tr
@@ -484,6 +512,18 @@
            {/each}
          {/each}
        </table>
+
      {:else if isImagePath(filePath) && extension && content}
+
        <div style:margin="1rem 0" style:text-align="center">
+
          <img
+
            src={`data:image/${extension};base64,${content}`}
+
            alt={filePath} />
+
        </div>
+
      {:else if preview && content}
+
        <div style:margin="1rem 0" style:text-align="center">
+
          <img
+
            src={`data:image/svg+xml;base64,${btoa(content)}`}
+
            alt={filePath} />
+
        </div>
      {:else}
        <div style:margin="1rem 0">
          <Placeholder iconName="empty-file" caption="Empty file" inline />
modified src/views/projects/Header/CloneButton.svelte
@@ -58,6 +58,7 @@
          <IconSmall name="logo" />
          Radicle
        </Button>
+
        <div class="global-spacer" />
        <Button
          styleWidth="100%"
          styleBorderRadius="0"
modified src/views/projects/Source/Blob.svelte
@@ -5,13 +5,14 @@
  import { toHtml } from "hast-util-to-html";

  import * as Syntax from "@app/lib/syntax";
-
  import { isMarkdownPath } from "@app/lib/utils";
+
  import { isImagePath, isMarkdownPath, isSvgPath } from "@app/lib/utils";
  import { lineNumbersGutter } from "@app/lib/syntax";
  import { routeToPath } from "@app/lib/router";

  import Button from "@app/components/Button.svelte";
  import File from "@app/components/File.svelte";
  import FilePath from "@app/components/FilePath.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
  import Link from "@app/components/Link.svelte";
  import Markdown from "@app/components/Markdown.svelte";
@@ -30,6 +31,7 @@
  $: lastCommit = blob.lastCommit;

  $: content = highlighted ? lineNumbersGutter(highlighted) : undefined;
+
  $: extension = path.split(".").pop();

  let selectedLineId: string | undefined = undefined;
  $: {
@@ -47,7 +49,10 @@
  }

  $: isMarkdown = isMarkdownPath(blob.path);
-
  $: showMarkdown = isMarkdown && selectedLineId === undefined;
+
  $: isImage = isImagePath(blob.path);
+
  $: isSvg = isSvgPath(blob.path);
+
  $: enablePreview = isMarkdown || isSvg;
+
  $: preview = enablePreview && selectedLineId === undefined;

  let linkBaseUrl: string | undefined;

@@ -197,41 +202,60 @@
      </div>
    </div>
    <div class="global-hide-on-mobile teaser-buttons">
-
      {#if isMarkdown}
+
      {#if enablePreview}
        <Radio ariaLabel="Toggle render method">
          <Button
            styleBorderRadius="0"
-
            variant={showMarkdown ? "selected" : "not-selected"}
+
            variant={!preview ? "selected" : "not-selected"}
            on:click={() => {
-
              window.location.hash = "";
-
              showMarkdown = true;
+
              preview = false;
            }}>
-
            Markdown
+
            <IconSmall name="chevron-left-right" />Code
          </Button>
          <Button
            styleBorderRadius="0"
-
            variant={!showMarkdown ? "selected" : "not-selected"}
+
            variant={preview ? "selected" : "not-selected"}
            on:click={() => {
-
              showMarkdown = false;
+
              window.location.hash = "";
+
              preview = true;
            }}>
-
            Plain
+
            <IconSmall name="eye-open" />Preview
          </Button>
+
          <div class="global-spacer" />
        </Radio>
      {/if}
-
      <a href="{rawPath}/{blob.path}">
-
        <Button variant="gray-white">Raw</Button>
+
      <a href="{rawPath}/{blob.path}" target="_blank" rel="noreferrer">
+
        <Button variant="gray-white">
+
          Raw <IconSmall name="arrow-box-up-right" />
+
        </Button>
      </a>
    </div>
  </svelte:fragment>

-
  {#if blob.binary}
-
    <div style:margin="4rem 0" style:width="100%">
-
      <Placeholder iconName="binary-file" caption="Binary file" />
-
    </div>
-
  {:else if showMarkdown && blob.content}
-
    <div style:padding="2rem">
-
      <Markdown {linkBaseUrl} content={blob.content} {rawPath} {path} />
-
    </div>
+
  {#if blob.binary && blob.content}
+
    {#if isImage && extension}
+
      <div style:margin="1rem 0" style:text-align="center">
+
        <img
+
          src={`data:image/${extension};base64,${blob.content}`}
+
          alt={path} />
+
      </div>
+
    {:else}
+
      <div style:margin="4rem 0" style:width="100%">
+
        <Placeholder iconName="binary-file" caption="Binary file" />
+
      </div>
+
    {/if}
+
  {:else if preview && blob.content}
+
    {#if isMarkdown}
+
      <div style:padding="2rem">
+
        <Markdown {linkBaseUrl} content={blob.content} {rawPath} {path} />
+
      </div>
+
    {:else if isSvg}
+
      <div style:margin="1rem 0" style:text-align="center">
+
        <img
+
          src={`data:image/svg+xml;base64,${btoa(blob.content)}`}
+
          alt={path} />
+
      </div>
+
    {/if}
  {:else if content}
    <table class="code no-scrollbar">
      {@html toHtml(content)}
modified src/views/projects/Source/BranchSelector.svelte
@@ -97,6 +97,8 @@
    {/if}
  {/if}

+
  <div class="global-spacer" />
+

  <Button
    title="Current HEAD"
    variant="not-selected"
modified tests/e2e/project.spec.ts
@@ -86,7 +86,7 @@ test("source file highlighting", async ({ page }) => {

test("navigate line numbers", async ({ page }) => {
  await page.goto(`${markdownUrl}/tree/main/cheatsheet.md`);
-
  await page.getByRole("button", { name: "Plain" }).click();
+
  await page.getByRole("button", { name: "Code" }).click();

  await page.getByRole("link", { name: "5", exact: true }).click();
  await expect(page.locator("#L5")).toHaveClass("line highlight");
@@ -103,7 +103,7 @@ test("navigate line numbers", async ({ page }) => {
  // Check that we go back to the Markdown view when navigating to a different
  // file.
  await page.getByRole("link", { name: "footnotes.md" }).click();
-
  await expect(page.getByRole("button", { name: "Markdown" })).toHaveClass(
+
  await expect(page.getByRole("button", { name: "Preview" })).toHaveClass(
    /selected/,
  );
});
@@ -229,21 +229,21 @@ test("markdown files", async ({ page }) => {

  // Switch between raw and rendered modes.
  {
-
    await expect(page.getByRole("button", { name: "Markdown" })).toHaveClass(
+
    await expect(page.getByRole("button", { name: "Preview" })).toHaveClass(
      /selected/,
    );
-
    await expect(page.getByRole("button", { name: "Plain" })).toHaveClass(
+
    await expect(page.getByRole("button", { name: "Code" })).toHaveClass(
      /not-selected/,
    );
-
    await page.getByRole("button", { name: "Plain" }).click();
-
    await expect(page.getByRole("button", { name: "Markdown" })).toHaveClass(
+
    await page.getByRole("button", { name: "Code" }).click();
+
    await expect(page.getByRole("button", { name: "Preview" })).toHaveClass(
      /not-selected/,
    );
-
    await expect(page.getByRole("button", { name: "Plain" })).toHaveClass(
+
    await expect(page.getByRole("button", { name: "Code" })).toHaveClass(
      /selected/,
    );
    await expect(page.getByText("##### Table of Contents")).toBeVisible();
-
    await page.getByRole("button", { name: "Markdown" }).click();
+
    await page.getByRole("button", { name: "Preview" }).click();
  }

  // Internal links go to anchor.