Organise files more consistently

Signed-off-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
Rūdolfs Ošiņš
2022-12-22 16:46:08 +01:00
parent e86accafae
commit caa83db480
130 changed files with 720 additions and 707 deletions
+250
View File
@@ -0,0 +1,250 @@
<script lang="ts">
import type { MaybeBlob, Blob } from "@app/lib/project";
import type { MaybeHighlighted } from "@app/lib/syntax";
import type { ProjectRoute } from "@app/lib/router/definitions";
import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
import Readme from "@app/views/projects/Readme.svelte";
import { afterUpdate, beforeUpdate, onMount } from "svelte";
import { highlight } from "@app/lib/syntax";
import { isMarkdownPath, scrollIntoView, twemoji } from "@app/lib/utils";
import { lineNumbersGutter } from "@app/lib/syntax";
import { toHtml } from "hast-util-to-html";
import { updateProjectRoute } from "@app/lib/router";
export let activeRoute: ProjectRoute;
export let blob: Blob;
export let getImage: (path: string) => Promise<MaybeBlob>;
export let line: string | undefined = undefined;
const fileExtension = blob.path.split(".").pop() ?? "";
const lastCommit = blob.info.lastCommit;
const parentDir = blob.path
.match(/^.*\/|/)
?.values()
.next().value;
let content: MaybeHighlighted = undefined;
// Any time a user clicks on a line number, the `line` prop gets updated,
// and the line is highlighted, but the previous line is not unhighlighted.
// So we have to make sure here that any previous highlighting gets removed,
// before updating the component.
beforeUpdate(() => {
for (const item of document.getElementsByClassName("highlight")) {
item.classList.remove("highlight");
}
});
onMount(async () => {
const output = await highlight(blob.content, fileExtension);
if (output) {
content = lineNumbersGutter(output);
}
});
afterUpdate(() => {
if (line) {
scrollIntoView(line);
const element = document.getElementById(line);
if (element) {
element.classList.add("highlight");
}
}
});
const isMarkdown = isMarkdownPath(blob.path);
// If we have a line number we should show the raw output.
let showMarkdown = line ? false : isMarkdown;
const toggleMarkdown = () => {
updateProjectRoute({ line: undefined });
showMarkdown = !showMarkdown;
};
</script>
<style>
header .file-header {
display: flex;
height: 3rem;
align-items: center;
justify-content: space-between;
padding: 0 0.5rem 0 1rem;
color: var(--color-foreground);
border-width: 1px 1px 0 1px;
border-color: var(--color-foreground-3);
border-style: solid;
border-top-left-radius: var(--border-radius-small);
border-top-right-radius: var(--border-radius-small);
}
.file-header .right {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
overflow-x: hidden;
text-overflow: ellipsis;
width: 100%;
}
header .file-name {
font-weight: var(--font-weight-normal);
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 1rem;
}
.last-commit {
padding: 0.5rem;
color: var(--color-secondary);
background-color: var(--color-secondary-2);
font-size: var(--font-size-tiny);
border-radius: var(--border-radius-small);
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last-commit .hash {
font-weight: var(--font-weight-bold);
font-family: var(--font-family-monospace);
margin-right: 0.25rem;
}
.markdown-toggle {
margin-right: 0.5rem;
}
.code :global(.line-number) {
color: var(--color-foreground-4);
text-align: right;
padding-right: 1rem;
padding-left: 1rem;
}
.code :global(.line-number:hover) {
cursor: pointer;
color: var(--color-foreground);
}
.code :global(.content) {
display: inline;
font-family: var(--font-family-monospace);
margin: 0;
}
.code :global(.line) {
line-height: 22px; /* This seems to be the line-height of a pre code block */
}
.code :global(.highlight) {
background-color: var(--color-caution-3);
}
.code :global(.highlight td a) {
color: var(--color-foreground);
}
.code :global(.line-content) {
padding: 0;
width: 100%;
}
.code {
width: 100%;
border-spacing: 0;
overflow-x: auto;
font-size: 1rem;
padding-top: 1rem;
margin-bottom: 1.5rem;
}
.container {
position: relative;
display: flex;
overflow-x: auto;
border: 1px solid var(--color-foreground-3);
border-top-style: dashed;
border-bottom-left-radius: var(--border-radius-small);
border-bottom-right-radius: var(--border-radius-small);
}
.binary {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 16rem;
background-color: var(--color-foreground-1);
color: var(--color-foreground-6);
font-family: var(--font-family-monospace);
}
.binary > * {
margin-bottom: 1rem;
}
.no-scrollbar {
scrollbar-width: none;
}
.markdown {
max-width: 64rem;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
@media (max-width: 960px) {
.code {
font-size: var(--font-size-small);
}
}
@media (max-width: 720px) {
.right {
justify-content: center;
}
}
</style>
<div class:markdown={isMarkdown}>
<header>
<div class="file-header">
<span class="file-name">
<span class="txt-faded">{parentDir}</span>
&#8203;
<span>{blob.info.name}</span>
</span>
<div class="right">
{#if isMarkdown}
<div class="markdown-toggle">
<HeaderToggleLabel
active={!showMarkdown}
clickable
on:click={toggleMarkdown}>
Raw
</HeaderToggleLabel>
</div>
{/if}
<div class="last-commit" title={lastCommit.author.name} use:twemoji>
<span class="hash">{lastCommit.sha1.slice(0, 7)}</span>
{lastCommit.summary}
</div>
</div>
</div>
</header>
<div class="container">
{#if blob.binary}
<div class="binary">
<div use:twemoji>👀</div>
<span class="txt-tiny">Binary content</span>
</div>
{:else if showMarkdown}
<Readme content={blob.content} {getImage} {activeRoute} />
{:else if content}
<table class="code no-scrollbar">
{@html toHtml(content)}
</table>
{/if}
</div>
</div>
+121
View File
@@ -0,0 +1,121 @@
<script lang="ts" strictEvents>
import { createEventDispatcher } from "svelte";
import type { ProjectInfo, Branches } from "@app/lib/project";
import { getOid } from "@app/lib/project";
import { formatCommit } from "@app/lib/utils";
import Dropdown from "@app/components/Dropdown.svelte";
import Floating from "@app/components/Floating.svelte";
export let branches: Branches;
export let project: ProjectInfo;
export let revision: string;
const dispatch = createEventDispatcher<{ branchChanged: string }>();
const switchBranch = (name: string) => {
dispatch("branchChanged", name);
};
let branchLabel: string | null = null;
$: branchList = Object.keys(branches)
.sort()
.map(b => ({ key: b, value: b, title: `Switch to ${b}`, badge: null }));
$: showSelector = branchList.length > 1;
$: head = project.head ?? branches[project.defaultBranch];
$: commit = getOid(revision, branches) || head;
$: if (commit === head) {
branchLabel = project.defaultBranch;
} else if (branches[revision]) {
branchLabel = revision;
} else {
branchLabel = null;
}
</script>
<style>
.commit {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-family-monospace);
}
.commit .branch {
padding: 0.5rem 0.75rem;
color: var(--color-secondary);
background-color: var(--color-secondary-2);
border-radius: var(--border-radius-small) 0 0 var(--border-radius-small);
}
.commit .branch.not-allowed {
cursor: not-allowed;
}
.branch:hover:not(.not-allowed) {
background-color: var(--color-foreground-2);
}
.commit .hash {
display: inline-block;
height: 2rem;
line-height: initial;
color: var(--color-secondary);
background-color: var(--color-secondary-1);
padding: 0.5rem 0.75rem;
border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0;
}
.commit .hash.unlabeled {
border-radius: var(--border-radius-small);
}
.stat {
font-family: var(--font-family-monospace);
padding: 0.5rem 0.75rem;
height: 2rem;
line-height: initial;
background: var(--color-foreground-1);
}
</style>
<div class="commit" title="Current branch">
<!-- Check for branches listing feature -->
{#if branchList.length > 0}
{#if branchLabel}
<Floating disabled={!showSelector}>
<div
slot="toggle"
title="Change branch"
class="stat branch"
class:not-allowed={!showSelector}>
{branchLabel}
</div>
<svelte:fragment slot="modal">
<Dropdown
items={branchList}
selected={branchLabel}
on:select={e => switchBranch(e.detail)} />
</svelte:fragment>
</Floating>
<div class="hash layout-desktop">
{formatCommit(commit)}
</div>
{:else}
<div class="unlabeled hash layout-desktop">
{commit}
</div>
{/if}
<div class="hash layout-mobile">
{formatCommit(commit)}
</div>
<!-- If there is no branch listing available, show default branch name if commit is head and else show entire commit -->
{:else if commit === head}
<div class="stat branch not-allowed">
{project.defaultBranch}
</div>
<div class="hash">
{formatCommit(commit)}
</div>
{:else}
<div class="unlabeled hash layout-desktop">
{commit}
</div>
<div class="hash layout-mobile">
{formatCommit(commit)}
</div>
{/if}
</div>
+259
View File
@@ -0,0 +1,259 @@
<script lang="ts" context="module">
import { writable } from "svelte/store";
export const browserErrorStore = writable<
{ message: string; path: string } | undefined
>();
</script>
<script lang="ts">
import type * as proj from "@app/lib/project";
import type { ProjectRoute } from "@app/lib/router/definitions";
import * as router from "@app/lib/router";
import * as utils from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import Loading from "@app/components/Loading.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import { onMount } from "svelte";
import Tree from "./Tree.svelte";
import Blob from "./Blob.svelte";
enum Status {
Loading,
Loaded,
}
type State =
| { status: Status.Loading; path: string }
| { status: Status.Loaded; path: string; blob: proj.Blob };
export let project: proj.Project;
export let tree: proj.Tree;
export let commit: string;
export let activeRoute: ProjectRoute;
$: path = activeRoute.params.path || "/";
$: line = activeRoute.params.line;
// When the component is loaded the first time, the blob is yet to be loaded.
let state: State = { status: Status.Loading, path };
// Whether the mobile file tree is visible.
let mobileFileTree = false;
const loadBlob = async (path: string) => {
if (state.status === Status.Loaded && state.path === path) {
return state.blob;
}
const promise =
path === "/" ? project.getReadme(commit) : project.getBlob(commit, path);
state = { status: Status.Loading, path };
state = { status: Status.Loaded, path, blob: await promise };
return state.blob;
};
onMount(() => {
browserErrorStore.set(undefined);
});
// Get an image blob based on a relative path.
const getImage = async (imagePath: string) => {
const finalPath = utils.canonicalize(imagePath, path);
return project.getBlob(commit, finalPath).catch(() => {
console.warn("Not able to load image blob:", finalPath);
return undefined;
});
};
const onSelect = async (newPath: string) => {
browserErrorStore.set(undefined);
// Ensure we don't spend any time in a "loading" state. This means
// the loading spinner won't be shown, and instead the blob will be
// displayed once loaded.
const blob = await loadBlob(newPath).catch(() => {
browserErrorStore.set({
message: "Not able to load selected file",
path: newPath,
});
return undefined;
});
if (blob) {
getBlob = new Promise(resolve => resolve(blob));
}
// Close mobile tree if user navigates to other file
mobileFileTree = false;
router.updateProjectRoute({
view: { resource: "tree" },
path: newPath,
});
};
const fetchTree = async (path: string) => {
return project.getTree(commit, path).catch(() => {
browserErrorStore.set({
message: "Not able to expand directory",
path,
});
return undefined;
});
};
const toggleMobileFileTree = () => {
mobileFileTree = !mobileFileTree;
};
$: getBlob = loadBlob(path).catch(() => {
browserErrorStore.set({ message: "Not able to load file", path });
return undefined;
});
$: loadingPath =
!$browserErrorStore && state.status === Status.Loading ? state.path : null;
</script>
<style>
.center-content {
margin: 0 auto;
}
.container {
display: flex;
width: inherit;
margin-bottom: 4rem;
padding: 0 2rem 0 8rem;
}
.column-left {
display: flex;
flex-direction: column;
padding-right: 1rem;
}
.column-right {
display: flex;
flex-direction: column;
padding-left: 1rem;
min-width: var(--content-min-width);
width: 100%;
}
.placeholder {
display: flex;
flex-direction: column;
width: 100%;
}
.source-tree {
overflow-x: hidden;
}
nav {
padding: 0 2rem;
}
.sticky {
position: sticky;
top: 2rem;
max-height: 100vh;
}
@media (max-width: 960px) {
.container {
padding-left: 2rem;
}
}
@media (max-width: 720px) {
.column-right {
padding: 1.5rem 0;
min-width: 0;
}
.placeholder {
padding: 1.5rem;
}
.source-tree {
padding: 0 2rem;
margin: 1rem 0;
}
.container {
padding: 0;
flex-direction: column;
}
.column-left {
display: none;
padding-right: 0;
}
.column-left-visible {
display: block;
}
.sticky {
max-height: initial;
}
}
</style>
<main>
<!-- Mobile navigation -->
{#if tree.entries.length > 0}
<nav class="layout-mobile">
<Button
style="width: 100%;"
variant="secondary"
on:click={toggleMobileFileTree}>
Browse
</Button>
</nav>
{/if}
<div class="container center-content">
{#if tree.entries.length > 0}
<div class="column-left" class:column-left-visible={mobileFileTree}>
<div class="source-tree sticky">
<Tree
{tree}
{path}
{fetchTree}
{loadingPath}
on:select={e => {
onSelect(e.detail);
}} />
</div>
</div>
<div class="column-right">
{#if $browserErrorStore}
<Placeholder emoji="🍂">
<span slot="title">
<div class="txt-monospace">{$browserErrorStore.path}</div>
</span>
<span slot="body">
<span>
{#if $browserErrorStore.path === "/"}
The README could not be loaded.
{:else}
{$browserErrorStore.message}
{/if}
</span>
</span>
</Placeholder>
{:else}
{#await getBlob}
<Loading small center />
{:then blob}
{#if blob}
<Blob {line} {blob} {getImage} {activeRoute} />
{/if}
{/await}
{/if}
</div>
{:else}
<div class="placeholder">
<Placeholder emoji="👀">
<span slot="title">Nothing to show</span>
<span slot="body">We couldn't find any files at this revision.</span>
</Placeholder>
</div>
{/if}
</div>
</main>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import * as utils from "@app/lib/utils";
import Clipboard from "@app/components/Clipboard.svelte";
import Floating from "@app/components/Floating.svelte";
import { closeFocused } from "@app/components/Floating.svelte";
export let seedHost: string;
export let urn: string;
$: radCloneUrl = `rad clone rad://${seedHost}/${utils.parseRadicleId(urn)}`;
$: gitCloneUrl = `https://${seedHost}/${utils.parseRadicleId(urn)}.git`;
</script>
<style>
.clone-button {
background-color: var(--color-caution-2);
border-radius: var(--border-radius-small);
color: var(--color-caution-6);
font-family: var(--font-family-monospace);
min-width: max-content;
height: 2rem;
line-height: initial;
padding: 0.5rem 0.75rem;
}
.clone-button:hover {
background-color: var(--color-caution-3);
}
.dropdown {
background-color: var(--color-foreground-1);
border-radius: var(--border-radius-small);
box-shadow: var(--elevation-low);
margin-top: 0.5rem;
padding: 1rem;
position: absolute;
width: 24rem;
z-index: 10;
}
@media (max-width: 720px) {
.dropdown {
width: auto;
left: 2rem;
right: 2rem;
z-index: 10;
}
}
label {
color: var(--color-foreground-6);
display: block;
font-size: var(--font-size-tiny);
padding: 0.5rem 0.5rem 0 0.25rem;
}
.clone-url-wrapper {
position: relative;
display: flex;
align-items: center;
}
.clone-url {
border-radius: var(--border-radius-small);
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
height: 2rem;
overflow: hidden;
padding: 0.5rem;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
color: var(--color-caution-6);
background: linear-gradient(var(--color-caution-2), var(--color-caution-2)),
linear-gradient(var(--color-background), var(--color-background));
}
.clipboard {
position: absolute;
right: 0;
color: var(--color-caution-6);
background: linear-gradient(var(--color-caution-2), var(--color-caution-2)),
linear-gradient(var(--color-background), var(--color-background));
visibility: hidden;
width: 4rem;
height: 2rem;
text-align: right;
-webkit-mask: linear-gradient(90deg, transparent 0%, #fff 50%);
mask: linear-gradient(90deg, transparent 0%, #fff 50%);
border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0;
}
.clone-url-wrapper:hover .clipboard {
visibility: visible;
}
</style>
<Floating>
<div slot="toggle" class="clone-button">Clone</div>
<svelte:fragment slot="modal">
<div class="dropdown">
<div class="clone-url-wrapper">
<div class="clone-url" name="rad-clone-url">{radCloneUrl}</div>
<span class="clipboard">
<Clipboard text={radCloneUrl} on:copied={closeFocused} />
</span>
</div>
<label for="rad-clone-url">
Use the <a
target="_blank"
rel="noreferrer"
href="https://radicle.xyz/get-started.html"
class="link">
Radicle CLI
</a>
to clone this project.
</label>
<br />
<div class="clone-url-wrapper">
<div class="clone-url" name="git-clone-url">{gitCloneUrl}</div>
<span class="clipboard">
<Clipboard text={gitCloneUrl} on:copied={closeFocused} />
</span>
</div>
<label for="git-clone-url">
Use Git to clone this repository from the URL above.
</label>
</div>
</svelte:fragment>
</Floating>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import type { Commit } from "@app/lib/commit";
import { formatCommit, twemoji } from "@app/lib/utils";
import Changeset from "@app/views/projects/SourceBrowser/Changeset.svelte";
import CommitAuthorship from "@app/views/projects/Commit/CommitAuthorship.svelte";
import CommitVerifiedBadge from "@app/views/projects/Commit/CommitVerifiedBadge.svelte";
import * as router from "@app/lib/router";
export let commit: Commit;
const onBrowse = (event: { detail: string }) => {
router.updateProjectRoute({
view: { resource: "tree" },
path: event.detail,
});
};
</script>
<style>
.commit {
padding: 0 2rem 0 8rem;
}
header {
padding: 1rem;
background: var(--color-foreground-1);
border-radius: var(--border-radius);
margin-bottom: 1.5rem;
}
.summary {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.description {
margin: 0.5rem 0 1rem 0;
}
.sha1 {
color: var(--color-foreground-5);
font-size: var(--font-size-small);
}
.authorship {
display: flex;
align-items: center;
justify-content: space-between;
}
@media (max-width: 960px) {
.commit {
padding-left: 2rem;
}
}
</style>
<div class="commit">
<header>
<div class="summary">
<div class="txt-medium" use:twemoji>{commit.header.summary}</div>
<div class="layout-desktop txt-monospace sha1">
<span>{commit.header.sha1}</span>
</div>
<div class="layout-mobile txt-monospace sha1 txt-small">
{formatCommit(commit.header.sha1)}
</div>
</div>
<pre class="description txt-small">{commit.header.description}</pre>
<div class="authorship">
<CommitAuthorship {commit} />
{#if commit.context?.committer}
<CommitVerifiedBadge {commit} />
{/if}
</div>
</header>
<Changeset stats={commit.stats} diff={commit.diff} on:browse={onBrowse} />
</div>
@@ -0,0 +1,100 @@
<script lang="ts">
import type { CommitMetadata } from "@app/lib/commit";
import Badge from "@app/components/Badge.svelte";
import { formatTimestamp, gravatarURL } from "@app/lib/utils";
export let commit: CommitMetadata;
export let noTime = false;
export let noAuthor = false;
export let noDelegate = false;
</script>
<style>
.authorship {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--color-foreground-5);
padding: 0.125rem 0;
}
.authorship .author,
.authorship .committer {
color: var(--color-foreground);
white-space: nowrap;
}
.authorship .avatar {
width: 1rem;
height: 1rem;
border-radius: var(--border-radius);
}
@media (max-width: 720px) {
.authorship {
display: none;
}
}
</style>
<span class="authorship txt-tiny">
{#if commit.header.author.email === commit.header.committer.email}
<img
class="avatar"
alt="avatar"
src={gravatarURL(commit.header.committer.email)} />
{#if commit.context?.committer}
<span
class="committer"
class:txt-bold={Boolean(commit.context.committer.peer.person?.name)}>
{commit.context.committer.peer.person?.name ||
commit.header.committer.name}
</span>
{#if !noDelegate && commit.context?.committer.peer.delegate}
<Badge variant="tertiary">delegate</Badge>
{/if}
<span>committed</span>
{:else}
<span class="layout-desktop-inline committer">
{commit.header.committer.name}
</span>
<span>committed</span>
{/if}
{:else}
{#if !noAuthor}
<img
class="avatar"
alt="avatar"
src={gravatarURL(commit.header.author.email)} />
<span class="layout-desktop-inline author">
{commit.header.author.name}
</span>
<span>authored</span>
{/if}
<img
class="avatar"
alt="avatar"
src={gravatarURL(commit.header.committer.email)} />
{#if commit.context?.committer}
<span
class="committer"
class:txt-bold={Boolean(commit.context.committer.peer.person?.name)}>
{commit.context.committer.peer.person?.name ||
commit.header.committer.name}
</span>
{#if !noDelegate && commit.context?.committer.peer.delegate}
<Badge variant="tertiary">delegate</Badge>
{/if}
<span>committed</span>
{:else}
<span class="layout-desktop-inline committer">
{commit.header.committer.name}
</span>
<span>committed</span>
{/if}
{/if}
{#if !noTime}
<span class="layout-desktop-inline">
{formatTimestamp(commit.header.committerTime)}
</span>
{/if}
</span>
@@ -0,0 +1,111 @@
<script lang="ts" strictEvents>
import type { CommitMetadata } from "@app/lib/commit";
import { formatCommit, twemoji } from "@app/lib/utils";
import { createEventDispatcher } from "svelte";
import Icon from "@app/components/Icon.svelte";
import CommitAuthorship from "./CommitAuthorship.svelte";
import CommitVerifiedBadge from "./CommitVerifiedBadge.svelte";
export let commit: CommitMetadata;
const dispatch = createEventDispatcher<{ browseCommit: string }>();
function browseCommit(commit: string) {
dispatch("browseCommit", commit);
}
</script>
<style>
.hash {
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
padding: 0 1.5rem;
}
.commit-teaser {
background-color: var(--color-foreground-1);
padding: 0.75rem 0rem;
}
.commit-teaser:hover {
background-color: var(--color-foreground-2);
}
.commit-teaser:first-child {
border-top-left-radius: var(--border-radius-small);
border-top-right-radius: var(--border-radius-small);
}
.commit-teaser:last-child {
border-bottom-left-radius: var(--border-radius-small);
border-bottom-right-radius: var(--border-radius-small);
}
.commit-teaser {
display: flex;
align-items: center;
justify-content: space-between;
}
.column-left {
padding-left: 1rem;
flex: min-content;
}
.commit-teaser .column-right {
display: flex;
align-items: center;
padding-right: 1.5rem;
}
.summary {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
.browse {
display: flex;
z-index: 10;
width: 100%;
height: 100%;
}
@media (max-width: 720px) {
.hash {
padding-right: 0;
}
.column-left {
overflow: hidden;
}
.browse {
display: none !important;
}
.summary {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
}
</style>
<div class="commit-teaser">
<div class="column-left">
<div class="header">
<div class="summary" use:twemoji>
{commit.header.summary}
</div>
</div>
<CommitAuthorship {commit} noDelegate />
</div>
<div class="column-right">
{#if commit.context.committer}
<div class="layout-desktop">
<CommitVerifiedBadge {commit} />
</div>
{/if}
<span class="hash txt-highlight">{formatCommit(commit.header.sha1)}</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="browse"
title="Browse the repository at this point in the history"
on:click|stopPropagation={() => browseCommit(commit.header.sha1)}>
<Icon name="browse" />
</div>
</div>
</div>
@@ -0,0 +1,87 @@
<script lang="ts">
import type { CommitMetadata } from "@app/lib/commit";
import debounce from "lodash/debounce";
import Badge from "@app/components/Badge.svelte";
import CommitAuthorship from "./CommitAuthorship.svelte";
export let commit: CommitMetadata;
let visible = false;
const showDelay = 50; // ms
const setVisible = debounce((value: boolean) => {
visible = value;
}, showDelay);
</script>
<style>
.container {
cursor: default;
}
.wrapper {
position: absolute;
}
.popup {
background-color: var(--color-foreground-1);
border-radius: var(--border-radius-small);
box-shadow: var(--elevation-low);
color: var(--color-foreground);
font-size: var(--font-size-tiny);
left: -10rem;
margin-top: 0.5rem;
padding: 0.5rem 0;
position: absolute;
min-width: 14rem;
z-index: 99;
}
.header {
display: flex;
padding: 1rem 0.75rem;
gap: 0.5rem;
}
.highlight {
color: var(--color-tertiary);
}
.committer {
border-top: 1px dashed var(--color-foreground-4);
padding: 0.75rem;
}
.peer {
padding-top: 0.5rem;
word-break: break-all;
color: var(--color-foreground-5);
}
</style>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="container"
on:click|stopPropagation
on:mouseenter={() => setVisible(true)}
on:mouseleave={() => setVisible(false)}>
<Badge variant="tertiary">Verified</Badge>
{#if visible}
<div class="wrapper">
<div class="popup">
<div class="header">
<div class="highlight">✔</div>
<div>
This commit was <span class="highlight">signed</span>
with the committer's radicle key.
</div>
</div>
<div class="committer">
<CommitAuthorship {commit} noAuthor noTime />
{#if commit.context.committer}
<div class="peer">
{commit.context.committer.peer.id}
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import { onMount } from "svelte";
import type { WeeklyActivity } from "@app/lib/commit";
export let strokeWidth: number;
export let points: WeeklyActivity[];
export let viewBoxWidth: number;
export let viewBoxHeight: number;
// The path strings to be inserted into the svg <path>
let path = "";
let areaPath = "";
const heightWithPadding = viewBoxHeight + 16;
// The latest point on the x axis, starting at 0 until `viewBoxWidth`
let lastWidthPoint = viewBoxWidth;
// The amount of points on the x axis
const widthIteration = viewBoxWidth / 52;
// The highest value on the y axis
const commitCountArray: number[] = [];
// The minimal amplitude shown e.g. commitCount = 1 => `minimalHeight` points of height in the SVG.
const minimalHeight = 5;
let week = 0;
for (const point of points) {
if (point.week - week > 1) {
commitCountArray.push(...new Array(point.week - week).fill(0));
}
commitCountArray.push(point.commits.length);
week = point.week;
}
// Formats the points passed in, into a svg path string, without closing the area
function createPath() {
let i = 1;
if (commitCountArray.length < 52) {
commitCountArray.push(...new Array(52 - commitCountArray.length).fill(0));
}
const maxValue = Math.max(...commitCountArray);
const minValue = Math.min(...commitCountArray);
// Normalizes the values to the viewBox dimensions
const normalizedArray = commitCountArray.map(c => {
// If we are not crossing the `viewBoxHeight` we want to return the actual value,
// and don't want to normalize <`minimalHeight` commit counts as huge spikes.
if (maxValue < viewBoxHeight && c >= minimalHeight) {
return c;
}
// If the value is 0..minimalHeight though we don't want to set it to the minimalHeight.
else if (c > 0 && c < minimalHeight) {
return minimalHeight;
}
// If the count is 0 we have to make sure the normalization is not being run since it would return NaN
else {
return c === 0
? 0
: ((viewBoxHeight - 0) * (c - minValue)) / (maxValue - minValue);
}
});
const path = normalizedArray.slice(1).reduce(
(acc, curr) => {
const s = `${viewBoxWidth - widthIteration * i},${
viewBoxHeight - curr
}`;
lastWidthPoint = viewBoxWidth - widthIteration * i;
i += 1;
return acc.concat(s);
},
[`M${viewBoxWidth},${viewBoxHeight - normalizedArray[0]}`],
);
return path.join();
}
onMount(() => {
// Creates the stroke path with the array of points
path = createPath();
// Concats a path closing for it to be the area under the stroke
areaPath = path.concat(
`L${lastWidthPoint},${viewBoxHeight}L${viewBoxWidth},${viewBoxHeight}Z`,
);
});
</script>
<svg
viewBox="0 0 {viewBoxWidth} {heightWithPadding}"
xmlns="http://www.w3.org/2000/svg">
<svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="fillGradient" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#ff55ff" stop-opacity="0" />
<stop offset="100%" stop-color="#ff55ff" stop-opacity="0.2" />
</linearGradient>
</defs>
</svg>
<svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradient" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#ff55ff" stop-opacity="0.2" />
<stop offset="50%" stop-color="#ff55ff" stop-opacity="0.8" />
<stop offset="100%" stop-color="#ff55ff" stop-opacity="1" />
</linearGradient>
</defs>
</svg>
{#if points.length > 0}
<g>
<path
fill="transparent"
stroke="url(#gradient)"
stroke-width={strokeWidth}
stroke-linejoin="round"
d={path} />
<path fill="url(#fillGradient)" stroke="transparent" d={areaPath} />
</g>
{:else}
<!-- If no commits have been made in a year, we show a straight line -->
<line
x1="0"
y1={viewBoxHeight}
x2="600"
y2={viewBoxHeight}
stroke="#ff55ff"
stroke-width={1} />
{/if}
</svg>
+148
View File
@@ -0,0 +1,148 @@
<script lang="ts">
import type { Project } from "@app/lib/project";
import type { Tree } from "@app/lib/project";
import type { ProjectRoute } from "@app/lib/router/definitions";
import * as router from "@app/lib/router";
import BranchSelector from "@app/views/projects/BranchSelector.svelte";
import CloneButton from "@app/views/projects/CloneButton.svelte";
import PeerSelector from "@app/views/projects/PeerSelector.svelte";
import { closeFocused } from "@app/components/Floating.svelte";
import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
export let activeRoute: ProjectRoute;
export let project: Project;
export let tree: Tree;
export let commit: string;
const { urn, peers, branches, seed } = project;
$: revision = activeRoute.params.revision ?? commit;
// Switches between project views.
const toggleContent = (
input: "patches" | "issues" | "history",
keepSourceInPath: boolean,
) => {
router.updateProjectRoute({
view: {
resource: activeRoute.params.view.resource === input ? "tree" : input,
},
urn: project.urn,
revision: revision,
...(keepSourceInPath ? null : { revision: undefined, path: undefined }),
});
};
const updatePeer = (peer: string) => {
router.updateProjectRoute({
peer,
revision: undefined,
});
closeFocused();
};
const updateRevision = (revision: string) => {
router.updateProjectRoute({
revision,
});
closeFocused();
};
function goToSeed() {
if (seed.api.port) {
router.push({
resource: "seeds",
params: { host: `${seed.api.host}:${seed.api.port}` },
});
} else {
router.push({ resource: "seeds", params: { host: seed.api.host } });
}
}
</script>
<style>
header {
font-size: var(--font-size-tiny);
padding: 0 2rem 0 8rem;
margin-bottom: 2rem;
display: flex;
align-items: center;
justify-content: left;
flex-wrap: wrap;
gap: 0.5rem;
}
@media (max-width: 960px) {
header {
padding-left: 2rem;
}
header {
margin-bottom: 1.5rem;
}
}
</style>
<header>
{#if peers.length > 0}
<PeerSelector
{peers}
peer={activeRoute.params.peer}
on:peerChanged={event => updatePeer(event.detail)} />
{/if}
<BranchSelector
{branches}
{project}
{revision}
on:branchChanged={event => updateRevision(event.detail)} />
{#if seed.git.host}
<CloneButton seedHost={seed.git.host} {urn} />
{/if}
<span>
{#if seed.api.host}
<HeaderToggleLabel
clickable
ariaLabel="Seed"
title="Project data is fetched from this seed"
on:click={goToSeed}>
<span>{seed.api.host}</span>
</HeaderToggleLabel>
{/if}
</span>
<HeaderToggleLabel
ariaLabel="Commit count"
clickable
active={activeRoute.params.view.resource === "history"}
on:click={() => toggleContent("history", true)}>
<span class="txt-bold">{tree.stats.commits}</span>
commit(s)
</HeaderToggleLabel>
{#if project.issues}
<HeaderToggleLabel
ariaLabel="Issue count"
active={activeRoute.params.view.resource === "issues"}
disabled={project.issues === 0}
clickable={project.issues > 0}
on:click={() => toggleContent("issues", false)}>
<span class="txt-bold">{project.issues}</span>
issue(s)
</HeaderToggleLabel>
{/if}
{#if project.patches}
<HeaderToggleLabel
ariaLabel="Patch count"
clickable={project.patches > 0}
active={activeRoute.params.view.resource === "patches"}
disabled={project.patches === 0}
on:click={() => toggleContent("patches", false)}>
<span class="txt-bold">{project.patches}</span>
patch(es)
</HeaderToggleLabel>
{/if}
<HeaderToggleLabel ariaLabel="Contributor count">
<span class="txt-bold">{tree.stats.contributors}</span>
contributor(s)
</HeaderToggleLabel>
</header>
@@ -0,0 +1,49 @@
<script lang="ts">
export let title: string | undefined = undefined;
export let ariaLabel: string | undefined = undefined;
export let active = false;
export let clickable = false;
export let disabled = false;
</script>
<style>
.stat {
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
padding: 0.5rem 0.75rem;
padding-bottom: 1rem; /* moving the content a tad higher to match the previous span usage */
height: 2rem;
background: var(--color-foreground-1);
border: none;
color: var(--color-foreground);
border-radius: var(--border-radius-small);
min-width: max-content;
}
.active {
color: var(--color-background);
background: var(--color-foreground) !important;
background-color: var(--color-foreground);
}
.clickable {
cursor: pointer;
}
.clickable:hover {
background-color: var(--color-foreground-2);
}
.not-allowed {
cursor: not-allowed;
color: var(--color-foreground-5);
}
</style>
<button
{title}
{disabled}
class:active
class:clickable
class:not-allowed={disabled}
class="stat"
aria-label={ariaLabel}
on:click>
<slot />
</button>
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import type { CommitMetadata, CommitsHistory } from "@app/lib/commit";
import CommitTeaser from "./Commit/CommitTeaser.svelte";
import { Project } from "@app/lib/project";
import { groupCommits } from "@app/lib/commit";
import List from "@app/components/List.svelte";
import * as router from "@app/lib/router";
export let project: Project;
export let history: CommitsHistory;
const fetchMoreCommits = async (): Promise<CommitMetadata[]> => {
const response = await Project.getCommits(project.urn, project.seed.api, {
// Fetching 31 elements since we remove the first one
parent: history.headers[history.headers.length - 1].header.sha1,
perPage: 31,
verified: true,
});
// Removing the first element of the array, since it's the same as the last of the current list
return response.headers.slice(1);
};
const browseCommit = (event: { detail: string }) => {
router.updateProjectRoute({
view: { resource: "tree" },
revision: event.detail,
});
};
</script>
<style>
.history {
padding: 0 2rem 0 8rem;
font-size: var(--font-size-small);
}
.commit-group header {
color: var(--color-foreground-6);
}
.commit-group-headers {
margin-bottom: 2rem;
}
.commit {
background-color: var(--color-foreground-1);
}
.commit:not(:last-child) {
border-bottom: 1px dashed var(--color-background);
}
.commit:hover {
background-color: var(--color-foreground-2);
cursor: pointer;
}
.commit:first-child {
border-top-left-radius: var(--border-radius-small);
border-top-right-radius: var(--border-radius-small);
}
.commit:last-child {
border-bottom-left-radius: var(--border-radius-small);
border-bottom-right-radius: var(--border-radius-small);
}
@media (max-width: 960px) {
.history {
padding-left: 2rem;
}
}
</style>
<div class="history">
<List bind:items={history.headers} query={fetchMoreCommits}>
<svelte:fragment slot="list" let:items>
{@const headers = groupCommits(items)}
{#each headers as group (group.time)}
<div class="commit-group">
<header class="commit-date">
<p>{group.date}</p>
</header>
<div class="commit-group-headers">
{#each group.commits as commit (commit.header.sha1)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="commit"
on:click={() => {
router.updateProjectRoute({
view: { resource: "commits" },
revision: commit.header.sha1,
});
}}>
<CommitTeaser {commit} on:browseCommit={browseCommit} />
</div>
{/each}
</div>
</div>
{/each}
</svelte:fragment>
</List>
</div>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { Blob, Project } from "@app/lib/project";
import { canonicalize, capitalize } from "@app/lib/utils";
import { formatObjectId } from "@app/lib/cobs";
import Comment from "@app/components/Comment.svelte";
import type { Issue } from "@app/lib/issue";
import Authorship from "@app/components/Authorship.svelte";
export let issue: Issue;
export let project: Project;
export let wallet: Wallet;
// Get an image blob based on a relative path.
const getImage = async (imagePath: string): Promise<Blob> => {
const finalPath = canonicalize(imagePath, "/"); // We only use the root path in issues.
const commit = project.branches[project.defaultBranch]; // We suppose that all issues are only looked at on HEAD of the default branch.
return project.getBlob(commit, finalPath);
};
</script>
<style>
header {
padding: 1rem;
background: var(--color-foreground-1);
border-radius: var(--border-radius);
margin-bottom: 2rem;
}
main {
display: flex;
}
.issue {
padding: 0 2rem 0 8rem;
}
.comments {
flex: 1;
}
.metadata {
flex-basis: 18rem;
margin-left: 1rem;
border-radius: var(--border-radius);
font-size: var(--font-size-small);
padding-left: 1rem;
}
.metadata-section {
margin-bottom: 1rem;
border-bottom: 1px dashed var(--color-foreground-4);
}
.metadata-section-header {
font-size: var(--font-size-small);
margin-bottom: 0.75rem;
color: var(--color-foreground-5);
}
.metadata-section-body {
margin-bottom: 1.25rem;
}
.metadata-section-empty {
color: var(--color-foreground-6);
}
.label {
border-radius: var(--border-radius);
color: var(--color-tertiary);
background-color: var(--color-tertiary-2);
padding: 0.25rem 0.75rem;
margin-right: 0.5rem;
font-size: var(--font-size-small);
line-height: 1.6;
}
.summary {
display: flex;
justify-content: space-between;
flex-direction: row;
align-items: center;
margin-bottom: 0.5rem;
}
.summary-left {
display: flex;
align-items: center;
}
.summary-title {
display: flex;
}
.id {
font-size: var(--font-size-tiny);
margin-left: 0.75rem;
color: var(--color-foreground-5);
}
.summary-state {
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
}
.open {
color: var(--color-positive);
background-color: var(--color-positive-2);
}
.closed {
color: var(--color-negative);
background-color: var(--color-negative-2);
}
.replies {
margin-left: 2rem;
}
@media (max-width: 960px) {
.issue {
padding-left: 2rem;
}
}
</style>
<div class="issue">
<header>
<div class="summary">
<div class="summary-left">
<span class="summary-title txt-medium">
{issue.title}
</span>
<span class="txt-monospace id layout-desktop">{issue.id}</span>
<span class="txt-monospace id layout-mobile">
{formatObjectId(issue.id)}
</span>
</div>
<div
class="summary-state"
class:closed={issue.state.status === "closed"}
class:open={issue.state.status === "open"}>
{capitalize(issue.state.status)}
</div>
</div>
<Authorship
{wallet}
author={issue.author}
timestamp={issue.timestamp}
caption="opened on" />
</header>
<main>
<div class="comments">
<Comment comment={issue.comment} {getImage} {wallet} />
{#each issue.discussion as comment}
<Comment {comment} {getImage} {wallet} />
{#if comment.replies}
<div class="replies">
{#each comment.replies as reply}
<Comment comment={reply} {getImage} {wallet} />
{/each}
</div>
{/if}
{/each}
</div>
<div class="metadata layout-desktop">
<div class="metadata-section">
<div class="metadata-section-header">Labels</div>
<div class="metadata-section-body">
{#if issue.labels?.length}
{#each issue.labels as label}
<span class="label">{label}</span>
{/each}
{:else}
<div class="metadata-section-empty">No labels.</div>
{/if}
</div>
</div>
</div>
</main>
</div>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import type { Issue } from "@app/lib/issue";
import type { Wallet } from "@app/lib/wallet";
import { Profile, ProfileType } from "@app/lib/profile";
import { formatObjectId } from "@app/lib/cobs";
import { onMount } from "svelte";
import { twemoji } from "@app/lib/utils";
import Authorship from "@app/components/Authorship.svelte";
export let issue: Issue;
export let wallet: Wallet;
let profile: Profile | null = null;
onMount(async () => {
if (issue.author.profile?.ens?.name) {
profile = await Profile.get(
issue.author.profile.ens.name,
ProfileType.Minimal,
wallet,
);
}
});
const commentCount = issue.countComments();
</script>
<style>
.issue-teaser {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-foreground-1);
padding: 0.75rem 0;
}
.issue-teaser:hover {
background-color: var(--color-foreground-2);
cursor: pointer;
}
.issue-id {
color: var(--color-foreground-5);
font-size: var(--font-size-tiny);
font-family: var(--font-family-monospace);
margin-left: 0.5rem;
}
.column-left {
flex: min-content;
}
.column-right {
display: flex;
align-items: center;
justify-content: flex-end;
margin-right: 1rem;
flex-basis: 5rem;
}
.comment-count {
color: var(--color-foreground-4);
font-weight: var(--font-weight-bold);
}
.comment-count .emoji {
margin-right: 0.25rem;
}
.state {
padding: 0 1rem;
}
.state-icon {
width: 0.5rem;
height: 0.5rem;
border-radius: var(--border-radius-small);
}
.open {
background-color: var(--color-positive);
}
.closed {
background-color: var(--color-negative);
}
.summary {
display: flex;
flex-direction: row;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
@media (max-width: 720px) {
.column-left {
overflow: hidden;
}
.summary {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
}
</style>
<div class="issue-teaser">
<div class="state">
<div
class="state-icon"
class:closed={issue.state.status === "closed"}
class:open={issue.state.status === "open"} />
</div>
<div class="column-left">
<div class="summary">
<!-- TODO: Truncation not working on overflow -->
{issue.title}
<span class="issue-id">{formatObjectId(issue.id)}</span>
</div>
<Authorship
{profile}
{wallet}
caption="opened"
author={issue.author}
timestamp={issue.timestamp} />
</div>
{#if commentCount > 0}
<div class="column-right">
<div class="comment-count">
<span class="txt-tiny emoji" use:twemoji>💬</span>
<span>{commentCount}</span>
</div>
</div>
{/if}
</div>
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts" context="module">
export type State = "open" | "closed";
</script>
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { Issue } from "@app/lib/issue";
import type { ToggleButtonOption } from "@app/components/ToggleButton.svelte";
import { capitalize } from "@app/lib/utils";
import { groupIssues } from "@app/lib/issue";
import * as router from "@app/lib/router";
import IssueTeaser from "@app/views/projects/Issue/IssueTeaser.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import ToggleButton from "@app/components/ToggleButton.svelte";
export let wallet: Wallet;
export let issues: Issue[];
export let state: State;
let options: ToggleButtonOption<State>[];
const { open, closed } = groupIssues(issues);
$: filteredIssues = state === "open" ? open : closed;
$: sortedIssues = filteredIssues.sort(
({ timestamp: t1 }, { timestamp: t2 }) => t2 - t1,
);
$: options = [
{
value: "open",
count: open.length,
},
{
value: "closed",
count: closed.length,
},
];
</script>
<style>
.issues {
padding: 0 2rem 0 8rem;
font-size: var(--font-size-small);
}
.issues-list {
border-radius: var(--border-radius);
overflow: hidden;
}
.teaser:not(:last-child) {
border-bottom: 1px dashed var(--color-background);
}
@media (max-width: 960px) {
.issues {
padding-left: 2rem;
}
}
</style>
<div class="issues">
<div style="margin-bottom: 1rem;">
<ToggleButton
{options}
on:select={e => {
router.updateProjectRoute({
search: e.detail,
});
}}
active={state} />
</div>
{#if filteredIssues.length}
<div class="issues-list">
{#each sortedIssues as issue}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="teaser"
on:click={() => {
router.updateProjectRoute({
view: {
resource: "issue",
params: { issue: issue.id },
},
});
}}>
<IssueTeaser {wallet} {issue} />
</div>
{/each}
</div>
{:else}
<Placeholder emoji="🍣">
<div slot="title">{capitalize(state)} issues</div>
<div slot="body">No issues matched the current filter</div>
</Placeholder>
{/if}
</div>
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { Project } from "@app/lib/project";
import { capitalize } from "@app/lib/utils";
import { Patch, PatchTab } from "@app/lib/patch";
import { formatObjectId } from "@app/lib/cobs";
import Authorship from "@app/components/Authorship.svelte";
import Changeset from "./SourceBrowser/Changeset.svelte";
import PatchSideBar from "./Patch/PatchSideBar.svelte";
import PatchTabBar from "./Patch/PatchTabBar.svelte";
import PatchTimeline from "./Patch/PatchTimeline.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import * as router from "@app/lib/router";
export let patch: Patch;
export let project: Project;
export let wallet: Wallet;
const onSwitch = ({ detail }: { detail: PatchTab }) => {
activeTab = detail;
};
const onRevisionChanged = ({ detail }: { detail: string }) => {
revisionNumber = parseInt(detail);
};
const onBrowse = (event: { detail: string }, revision: string) => {
router.updateProjectRoute({
view: { resource: "tree" },
revision,
path: event.detail,
});
};
let activeTab = PatchTab.Timeline;
let revisionNumber = patch.revisions.length - 1;
$: revision = patch.revisions[revisionNumber];
</script>
<style>
header {
padding: 1rem;
background: var(--color-foreground-1);
border-radius: var(--border-radius);
}
.patch {
padding: 0 2rem 0 8rem;
}
.summary {
display: flex;
justify-content: space-between;
flex-direction: row;
align-items: center;
margin-bottom: 0.5rem;
}
.summary-left {
display: flex;
flex-direction: column;
}
.summary-title {
display: flex;
margin-right: 0.75rem;
}
.id {
font-size: var(--font-size-tiny);
color: var(--color-foreground-5);
}
.summary-state {
padding: 0.5rem 1rem;
border-radius: 1.25rem;
}
.proposed {
color: var(--color-positive-6);
background-color: var(--color-positive-1);
}
.draft {
color: var(--color-positive-6);
background-color: var(--color-positive-1);
}
.archived {
color: var(--color-negative-6);
background-color: var(--color-negative-1);
}
.flex {
display: flex;
}
@media (max-width: 960px) {
.patch {
padding-left: 2rem;
}
}
</style>
<div class="patch">
<header>
<div class="summary">
<div class="summary-left">
<span class="summary-title txt-medium">
{patch.title}
</span>
<span class="txt-monospace id layout-desktop">{patch.id}</span>
<span class="txt-monospace id layout-mobile">
{formatObjectId(patch.id)}
</span>
</div>
<div
class="summary-state"
class:proposed={patch.state === "proposed"}
class:draft={patch.state === "draft"}
class:archived={patch.state === "archived"}>
{capitalize(patch.state)}
</div>
</div>
<Authorship
noAvatar
{wallet}
author={patch.author}
timestamp={patch.timestamp}
caption="opened" />
</header>
<PatchTabBar
{activeTab}
{revisionNumber}
revisions={patch.revisions}
on:switchTab={onSwitch}
on:revisionChanged={onRevisionChanged} />
<main>
{#if activeTab === PatchTab.Timeline}
<div class="flex">
<PatchTimeline {patch} {revisionNumber} {wallet} {project} />
<PatchSideBar {patch} />
</div>
{:else if activeTab === PatchTab.Diff && revision.changeset}
<Changeset
diff={revision.changeset.diff}
stats={revision.changeset.stats}
on:browse={e => onBrowse(e, revision.oid)} />
{:else if activeTab === PatchTab.Diff}
<Placeholder emoji="🍳">
<span slot="title">No changeset found</span>
<span slot="body">
We couldn't find a changeset related to this patch or revision
</span>
</Placeholder>
{/if}
</main>
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import type { Patch } from "@app/lib/patch";
export let patch: Patch;
</script>
<style>
.metadata {
flex-basis: 18rem;
margin-left: 1rem;
border-radius: var(--border-radius-medium);
font-size: var(--font-size-small);
padding-left: 1rem;
}
.metadata-section {
margin-bottom: 1rem;
border-bottom: 1px dashed var(--color-foreground-4);
}
.metadata-section-header {
font-size: var(--font-size-small);
margin-bottom: 0.75rem;
color: var(--color-foreground-5);
}
.metadata-section-body {
margin-bottom: 1.25rem;
}
.metadata-section-empty {
color: var(--color-foreground-6);
}
.label {
border-radius: var(--border-radius);
color: var(--color-tertiary);
background-color: var(--color-tertiary-2);
padding: 0.25rem 0.75rem;
margin-right: 0.5rem;
font-size: var(--font-size-small);
line-height: 1.6;
}
</style>
<div class="metadata layout-desktop">
<div class="metadata-section">
<div class="metadata-section-header">Labels</div>
<div class="metadata-section-body">
{#if patch.labels?.length}
{#each patch.labels as label}
<span class="label">{label}</span>
{/each}
{:else}
<div class="metadata-section-empty">No labels.</div>
{/if}
</div>
</div>
</div>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts" strictEvents>
import type { ToggleButtonOption } from "@app/components/ToggleButton.svelte";
import Dropdown from "@app/components/Dropdown.svelte";
import Floating, { closeFocused } from "@app/components/Floating.svelte";
import ToggleButton from "@app/components/ToggleButton.svelte";
import type { Revision } from "@app/lib/patch";
import { PatchTab } from "@app/lib/patch";
import { formatCommit, formatTimestamp } from "@app/lib/utils";
import { createEventDispatcher } from "svelte";
export let revisions: Revision[];
export let revisionNumber: number;
export let activeTab: PatchTab;
const dispatch = createEventDispatcher<{
switchTab: PatchTab;
revisionChanged: string;
}>();
const formatRevisionName = (revision: Revision, index: number) => {
return `R${index} ${formatCommit(revision.oid)} ${formatTimestamp(
revision.timestamp,
)}`;
};
const revisionList = Object.values(revisions).map((b, i) => ({
key: formatRevisionName(b, i),
value: i.toString(),
title: `Browse revision ${formatRevisionName(b, i)}`,
badge: null,
}));
const onRevisionChange = ({ detail }: { detail: string }) => {
closeFocused();
dispatch("revisionChanged", detail);
};
let options: ToggleButtonOption<PatchTab>[];
$: options = [
{
title: "Patch",
value: PatchTab.Timeline,
},
{
title: "Changeset",
value: PatchTab.Diff,
},
];
</script>
<style>
.bar {
align-items: center;
display: flex;
gap: 1rem;
margin: 1.5rem 0;
}
.revision-toggle {
border-radius: var(--border-radius-small);
border: none;
color: var(--color-foreground-6);
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
height: var(--button-tiny-height);
padding: 0.25rem 0.5rem;
background-color: var(--color-background);
}
.revision-toggle:hover {
background-color: var(--color-foreground-1);
color: var(--color-foreground);
}
.revision-toggle:disabled {
color: var(--color-foreground-5);
}
</style>
<div class="bar txt-small">
<ToggleButton
{options}
on:select={e => {
dispatch("switchTab", e.detail);
}}
active={activeTab} />
<Floating disabled={revisions.length <= 1}>
<button
slot="toggle"
class="txt-small revision-toggle"
disabled={revisions.length <= 1}>
{formatRevisionName(revisions[revisionNumber], revisionNumber)}
</button>
<svelte:fragment slot="modal">
<Dropdown
items={revisionList}
selected={revisionNumber.toString()}
on:select={onRevisionChange} />
</svelte:fragment>
</Floating>
</div>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import type { Patch } from "@app/lib/patch";
import type { Wallet } from "@app/lib/wallet";
import { Profile, ProfileType } from "@app/lib/profile";
import { formatObjectId } from "@app/lib/cobs";
import { onMount } from "svelte";
import { twemoji } from "@app/lib/utils";
import Authorship from "@app/components/Authorship.svelte";
export let patch: Patch;
export let wallet: Wallet;
let profile: Profile | null = null;
onMount(async () => {
if (patch.author.profile?.ens?.name) {
profile = await Profile.get(
patch.author.profile.ens.name,
ProfileType.Minimal,
wallet,
);
}
});
const commentCount = patch.countComments(patch.revisions.length - 1);
</script>
<style>
.patch-teaser {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-foreground-1);
padding: 0.75rem 0;
}
.patch-teaser:hover {
background-color: var(--color-foreground-2);
cursor: pointer;
}
.patch-id {
color: var(--color-foreground-5);
font-size: var(--font-size-tiny);
font-family: var(--font-family-monospace);
margin-left: 0.5rem;
}
.column-left {
flex: min-content;
}
.column-right {
display: flex;
align-items: center;
justify-content: flex-end;
margin-right: 1rem;
flex-basis: 5rem;
}
.comment-count {
color: var(--color-foreground-4);
font-weight: var(--font-weight-bold);
}
.comment-count .emoji {
margin-right: 0.25rem;
}
.state {
padding: 0 1rem;
}
.state-icon {
width: 0.5rem;
height: 0.5rem;
border-radius: 0.5rem;
}
.open {
background-color: var(--color-positive);
}
.closed {
background-color: var(--color-negative);
}
.summary {
display: flex;
flex-direction: row;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
@media (max-width: 720px) {
.column-left {
overflow: hidden;
}
.summary {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
}
</style>
<div class="patch-teaser">
<div class="state">
<div
class="state-icon"
class:closed={patch.state === "archived"}
class:open={patch.state === "proposed"} />
</div>
<div class="column-left">
<div class="summary">
<!-- TODO: Truncation not working on overflow -->
{patch.title}
<span class="patch-id">{formatObjectId(patch.id)}</span>
</div>
<Authorship
{profile}
{wallet}
caption="opened"
author={patch.author}
timestamp={patch.timestamp} />
</div>
{#if commentCount > 0}
<div class="column-right">
<div class="comment-count">
<span class="txt-tiny emoji" use:twemoji>💬</span>
<span>{commentCount}</span>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,77 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import { type Patch, TimelineType } from "@app/lib/patch";
import { formatSeedId } from "@app/lib/utils";
import { canonicalize } from "@app/lib/utils";
import Comment from "@app/components/Comment.svelte";
import type { Blob, Project } from "@app/lib/project";
import Authorship from "@app/components/Authorship.svelte";
import Review from "@app/views/projects/Patch/Review.svelte";
export let patch: Patch;
export let revisionNumber: number;
export let wallet: Wallet;
export let project: Project;
$: timeline = patch.createTimeline(revisionNumber);
// Get an image blob based on a relative path.
const getImage = async (imagePath: string): Promise<Blob> => {
const finalPath = canonicalize(imagePath, "/"); // We only use the root path in issues.
const commit = project.branches[project.defaultBranch]; // We suppose that all issues are only looked at on HEAD of the default branch.
return project.getBlob(commit, finalPath);
};
</script>
<style>
.timeline {
display: flex;
flex-direction: column;
flex: 1;
}
.replies {
margin-left: 2rem;
}
.element {
margin: 0 0 1rem 3rem;
}
</style>
<div class="timeline">
{#each timeline as element}
{#if element.type === TimelineType.Merge && element.inner.peer.person}
<div class="element">
<Authorship
author={{
peer: element.inner.peer.id,
urn: element.inner.peer.person.urn,
profile: element.inner.peer.person,
}}
caption={`merged to ${formatSeedId(element.inner.peer.id)}`}
timestamp={element.timestamp}
{wallet} />
</div>
{:else if element.type === TimelineType.Review && element.inner.author.profile?.ens?.name}
<div class="margin-left">
<Review review={element.inner} {wallet} {getImage} />
</div>
{:else if element.type === TimelineType.Comment}
<div class="margin-left">
<!-- Since the element variable only experiences changes on the inner property,
this component has to be forced to be rerendered when element.inner changes -->
{#key element.inner}
<Comment comment={element.inner} {wallet} {getImage} />
{/key}
</div>
{:else if element.type === TimelineType.Thread}
<div class="margin-left">
<Comment comment={element.inner} {wallet} {getImage} />
<div class="replies">
{#each element.inner.replies as comment}
<Comment caption="replied" {comment} {wallet} {getImage} />
{/each}
</div>
</div>
{/if}
{/each}
</div>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Wallet } from "@app/lib/wallet";
import type { Review } from "@app/lib/patch";
import { formatVerdict } from "@app/lib/patch";
import type { Blob } from "@app/lib/project";
import { Profile, ProfileType } from "@app/lib/profile";
import Authorship from "@app/components/Authorship.svelte";
import Comment from "@app/components/Comment.svelte";
export let review: Review;
export let wallet: Wallet;
export let getImage: (path: string) => Promise<Blob>;
let profile: Profile | null = null;
onMount(async () => {
if (review.author.profile?.ens?.name) {
profile = await Profile.get(
review.author.profile.ens.name,
ProfileType.Minimal,
wallet,
);
}
});
</script>
<style>
div {
margin: 0 0 1rem 3rem;
}
</style>
{#if review.comment.body}
<Comment
{wallet}
{getImage}
comment={review.comment}
caption={formatVerdict(review.verdict)} />
{:else}
<div>
<Authorship
{wallet}
{profile}
author={review.author}
timestamp={review.timestamp}
caption={formatVerdict(review.verdict)} />
</div>
{/if}
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts" context="module">
export type State = "proposed" | "draft" | "archived";
</script>
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { Patch } from "@app/lib/patch";
import type { ToggleButtonOption } from "@app/components/ToggleButton.svelte";
import PatchTeaser from "./Patch/PatchTeaser.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import ToggleButton from "@app/components/ToggleButton.svelte";
import { capitalize } from "@app/lib/utils";
import { groupPatches } from "@app/lib/patch";
import * as router from "@app/lib/router";
export let state: State;
export let wallet: Wallet;
export let patches: Patch[];
let options: ToggleButtonOption<State>[];
const sortedPatches = groupPatches(patches);
$: filteredPatches = sortedPatches[state];
$: options = [
{
value: "proposed",
count: sortedPatches.proposed.length,
},
{
value: "draft",
count: sortedPatches.draft.length,
},
{
value: "archived",
count: sortedPatches.archived.length,
},
];
</script>
<style>
.patches {
padding: 0 2rem 0 8rem;
font-size: var(--font-size-small);
}
.patches-list {
border-radius: var(--border-radius-small);
overflow: hidden;
}
.teaser:not(:last-child) {
border-bottom: 1px dashed var(--color-background);
}
@media (max-width: 960px) {
.patches {
padding-left: 2rem;
}
}
</style>
<div class="patches">
<div style="margin-bottom: 1rem;">
<ToggleButton
{options}
on:select={e => {
router.updateProjectRoute({
search: e.detail,
});
}}
active={state} />
</div>
{#if filteredPatches.length}
<div class="patches-list">
{#each filteredPatches as patch}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="teaser"
on:click={() => {
router.updateProjectRoute({
view: { resource: "patch", params: { patch: patch.id } },
});
}}>
<PatchTeaser {wallet} {patch} />
</div>
{/each}
</div>
{:else}
<Placeholder emoji="🍖">
<div slot="title">{capitalize(state)} patches</div>
<div slot="body">No patches matched the current filter</div>
</Placeholder>
{/if}
</div>
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts" strictEvents>
import { createEventDispatcher, onMount } from "svelte";
import Icon from "@app/components/Icon.svelte";
import Dropdown from "@app/components/Dropdown.svelte";
import { formatSeedId } from "@app/lib/utils";
import type { Peer } from "@app/lib/project";
import Floating from "@app/components/Floating.svelte";
import Badge from "@app/components/Badge.svelte";
export let peer: string | null = null;
export let peers: Peer[];
let meta: Peer | undefined;
// List of items to be created for the Dropdown component.
let items: {
key: string;
value: string;
title: string;
badge: string | null;
}[] = [];
function createTitle(p: Peer): string {
const name = p.person?.name ? p.person.name : p.id;
return p.delegate
? `${name} is a delegate of this project`
: `${name} is a peer tracked by this seed`;
}
onMount(() => {
meta = peers.find(p => p.id === peer);
items = peers.map(p => {
if (!p.person?.name) {
console.debug("Not able to resolve peer identity for: ", p.id);
}
const key = p.person?.name
? `<span class="txt-bold">${p.person.name}</span> ${p.id}`
: p.id;
return {
key,
value: p.id,
title: createTitle(p),
badge: p.delegate ? "delegate" : null,
};
});
});
const dispatch = createEventDispatcher<{ peerChanged: string }>();
const switchPeer = (peer: string) => {
dispatch("peerChanged", peer);
};
</script>
<style>
.selector {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-family-monospace);
}
.selector .peer {
padding: 0.5rem;
color: var(--color-secondary);
background-color: var(--color-secondary-2);
border-radius: var(--border-radius-small);
}
.selector .peer.not-allowed {
cursor: not-allowed;
}
.peer-id {
margin: 0 0.5rem;
}
.peer:hover {
background-color: var(--color-foreground-2);
}
.stat {
display: flex;
align-items: center;
font-family: var(--font-family-monospace);
padding: 0.5rem;
height: 2rem;
line-height: initial;
background: var(--color-foreground-1);
}
</style>
<Floating>
<div slot="toggle" class="selector" title="Change peer">
<div class="stat peer" class:not-allowed={!peers}>
<Icon name="fork" />
{#if meta}
<span class="peer-id">
{meta.person?.name ?? formatSeedId(meta.id)}
</span>
{#if meta.delegate}
<Badge variant="primary">delegate</Badge>
{/if}
<!-- If the delegate metadata is not found -->
{:else if peer}
<span class="peer-id">
{formatSeedId(peer)}
</span>
{/if}
</div>
</div>
<svelte:fragment slot="modal">
<Dropdown {items} selected={peer} on:select={e => switchPeer(e.detail)} />
</svelte:fragment>
</Floating>
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import type { PeerId, Project } from "@app/lib/project";
import Avatar from "@app/components/Avatar.svelte";
import Clipboard from "@app/components/Clipboard.svelte";
import Link from "@app/components/Link.svelte";
import ProjectLink from "@app/components/ProjectLink.svelte";
import { formatSeedId } from "@app/lib/utils";
export let project: Project;
export let peer: PeerId | null = null;
</script>
<style>
.title {
display: flex;
align-items: center;
justify-content: left;
font-size: var(--font-size-huge);
margin-bottom: 0.5rem;
}
.title .divider {
color: var(--color-foreground-4);
margin: 0 0.5rem;
font-weight: var(--font-weight-normal);
}
.title .peer-id {
color: var(--color-foreground-5);
font-weight: var(--font-weight-normal);
display: flex;
align-items: center;
}
.org-avatar {
display: inline-block;
width: 2rem;
height: 2rem;
}
.project-name:hover {
color: inherit;
}
.urn {
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
color: var(--color-foreground-5);
overflow-wrap: anywhere;
display: flex;
justify-content: left;
align-items: center;
gap: 0.125rem;
}
.description {
margin: 1rem 0 1.5rem 0;
}
.content {
padding: 0 2rem 0 8rem;
}
.truncate {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
@media (max-width: 960px) {
.content {
padding-left: 2rem;
}
}
</style>
<header class="content">
<div class="title txt-bold txt-title">
{#if project.profile}
<Link
route={{
resource: "profile",
params: { addressOrName: project.profile.addressOrName },
}}
title={project.profile.addressOrName}>
<span class="org-avatar">
<Avatar
source={project.profile.avatar || project.profile.address}
title={project.profile.address} />
</span>
</Link>
<span class="divider">/</span>
{/if}
<span class="truncate">
<ProjectLink
projectParams={{
view: { resource: "tree" },
path: "/",
peer: undefined,
route: undefined,
revision: undefined,
}}>
<span class="project-name">
{project.name}
</span>
</ProjectLink>
</span>
{#if peer}
<span class="peer-id">
<span class="divider">/</span>
<span title={peer}>{formatSeedId(peer)}</span>
<Clipboard text={peer} />
</span>
{/if}
</div>
<div class="urn">
<span class="truncate">{project.urn}</span>
<Clipboard small text={project.urn} />
</div>
<div class="description">{project.description}</div>
</header>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type * as proj from "@app/lib/project";
import type { ProjectRoute } from "@app/lib/router/definitions";
import Markdown from "@app/components/Markdown.svelte";
export let content: string;
export let getImage: (path: string) => Promise<proj.MaybeBlob>;
export let activeRoute: ProjectRoute;
$: hash = activeRoute.params.hash || null;
</script>
<style>
article {
padding: 2rem;
width: 100%;
background: var(--color-foreground-1);
border-bottom-left-radius: var(--border-radius-small);
border-bottom-right-radius: var(--border-radius-small);
}
</style>
<article>
<Markdown {content} {getImage} {hash} />
</article>
@@ -0,0 +1,55 @@
<script lang="ts">
import type { DiffStats } from "@app/lib/commit";
import type { Diff } from "@app/lib/diff";
import FileDiff from "@app/views/projects/SourceBrowser/FileDiff.svelte";
export let diff: Diff;
export let stats: DiffStats;
const diffDescription = ({ modified, created, deleted }: Diff): string => {
const s = [];
if (modified.length) {
s.push(`${modified.length} file(s) changed`);
}
if (created.length) {
s.push(`${created.length} file(s) created`);
}
if (deleted.length) {
s.push(`${deleted.length} file(s) deleted`);
}
return s.join(", ");
};
</script>
<style>
.changeset-summary {
padding-bottom: 1.5rem;
margin-left: 1rem;
}
.changeset-summary .additions {
color: var(--color-positive);
}
.changeset-summary .deletions {
color: var(--color-negative);
}
</style>
<div class="changeset-summary">
<span>{diffDescription(diff)}</span>
with
<span class="additions">{stats.additions} addition(s)</span>
and
<span class="deletions">{stats.deletions} deletion(s)</span>
</div>
<div class="diff-listing">
{#each diff.created as file}
<FileDiff on:browse {file} mode="created" />
{/each}
{#each diff.deleted as file}
<FileDiff on:browse {file} mode="deleted" />
{/each}
{#each diff.modified as file}
<FileDiff on:browse {file} />
{/each}
</div>
@@ -0,0 +1,167 @@
<script lang="ts" strictEvents>
import { createEventDispatcher } from "svelte";
import Icon from "@app/components/Icon.svelte";
import { lineNumberL, lineNumberR, lineSign } from "@app/lib/diff";
import type { FileDiff } from "@app/lib/diff";
import Badge from "@app/components/Badge.svelte";
const dispatch = createEventDispatcher<{ browse: string }>();
export let file: FileDiff;
export let mode: string | null = null;
function collapse() {
collapsed = !collapsed;
}
let collapsed = false;
</script>
<style>
.changeset-file {
border: 1px solid var(--color-foreground-4);
border-radius: var(--border-radius-small);
min-width: var(--content-min-width);
margin-bottom: 2rem;
line-height: 1.5rem;
}
.changeset-file header {
cursor: pointer;
height: 3rem;
display: flex;
align-items: center;
background: none;
border-radius: 0;
padding: 1rem;
}
main {
border-top: 1px dashed var(--color-foreground-4);
}
.changeset-file main {
overflow-x: auto;
}
header {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
}
.binary {
padding: 1rem;
color: var(--color-foreground-5);
text-align: center;
background-color: var(--color-foreground-2);
}
table.diff {
font-family: var(--font-family-monospace);
table-layout: fixed;
border-collapse: collapse;
margin: 0.5rem 0;
}
tr.diff-line[data-type="+"] > * {
color: var(--color-positive);
}
tr.diff-line[data-type="-"] > * {
color: var(--color-negative);
}
td.diff-line-number {
text-align: right;
user-select: none;
line-height: 1.5rem;
min-width: 3rem;
}
td.diff-line-number[data-type="+"],
td.diff-line-type[data-type="+"] {
background-color: var(--color-positive-2);
color: var(--color-positive-6);
}
td.diff-line-number[data-type="-"],
td.diff-line-type[data-type="-"] {
background-color: var(--color-negative-2);
color: var(--color-negative-6);
}
td.diff-line-number.left {
padding: 0 0.5rem 0 0.75rem;
}
td.diff-line-number.right {
padding: 0 0.75rem 0 0.5rem;
}
td.diff-line-content {
white-space: pre;
width: 100%;
padding-right: 0.5rem;
}
td.diff-line-type {
text-align: center;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
td.diff-expand-header {
padding-left: 0.5rem;
color: var(--color-foreground-5);
}
td.diff-line-number {
color: var(--color-foreground-5);
}
.browse {
display: flex;
}
</style>
<article id={file.path} class="changeset-file">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<header class="file-header" on:click={collapse}>
<div class="actions">
<p class="txt-regular">{file.path}</p>
{#if mode === "created"}
<Badge variant="positive">created</Badge>
{:else if mode === "deleted"}
<Badge variant="negative">deleted</Badge>
{/if}
</div>
<div
class="browse clickable"
on:click|stopPropagation={() => dispatch("browse", file.path)}>
<span title="View file" style="transform: scale(1.25);">
<Icon name="browse" />
</span>
</div>
</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>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts" strictEvents>
import type { MaybeTree, Tree } from "@app/lib/project";
import { createEventDispatcher } from "svelte";
import { ObjectType } from "@app/lib/project";
import File from "./Tree/File.svelte";
import Folder from "./Tree/Folder.svelte";
export let fetchTree: (path: string) => Promise<MaybeTree>;
export let path: string;
export let tree: Tree;
export let loadingPath: string | null = null;
const dispatch = createEventDispatcher<{ select: string }>();
const onSelect = ({ detail: path }: { detail: string }): void => {
dispatch("select", path);
};
</script>
{#each tree.entries as entry (entry.path)}
{#if entry.info.objectType === ObjectType.Tree}
<Folder
{fetchTree}
{loadingPath}
name={entry.info.name}
prefix={`${entry.path}/`}
currentPath={path}
on:select={onSelect} />
{:else}
<File
active={entry.path === path}
loading={entry.path === loadingPath}
name={entry.info.name}
on:click={() => onSelect({ detail: entry.path })} />
{/if}
{/each}
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import Loading from "@app/components/Loading.svelte";
export let active: boolean;
export let loading: boolean;
export let name: string;
</script>
<style>
.file {
color: var(--color-foreground-6);
border-radius: var(--border-radius-small);
cursor: pointer;
display: flex;
justify-content: space-between;
line-height: 1.5em;
margin: 0.125rem 0;
padding: 0.25rem;
width: 100%;
}
.file:hover {
background-color: var(--color-foreground-1);
}
.file.active {
color: var(--color-foreground) !important;
background-color: var(--color-foreground-1);
}
.spinner {
align-items: center;
display: flex;
justify-content: center;
height: 24px;
width: 24px;
}
.name {
margin-left: 0.25rem;
user-select: none;
white-space: nowrap;
text-overflow: ellipsis !important;
overflow: hidden;
max-width: 24ch;
}
</style>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="file" class:active on:click>
<span class="name">{name}</span>
<div class="spinner">
{#if loading}
<Loading small condensed />
{/if}
</div>
</div>
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts" strictEvents>
import type { MaybeTree } from "@app/lib/project";
import Loading from "@app/components/Loading.svelte";
import { ObjectType } from "@app/lib/project";
import { createEventDispatcher } from "svelte";
import File from "./File.svelte";
export let fetchTree: (path: string) => Promise<MaybeTree>;
export let name: string;
export let prefix: string;
export let currentPath: string;
export let loadingPath: string | null = null;
let expanded = currentPath.indexOf(prefix) === 0;
let tree: Promise<MaybeTree> = fetchTree(prefix).then(tree => {
if (expanded) return tree;
});
const dispatch = createEventDispatcher<{ select: string }>();
const onSelectFile = ({ detail: path }: { detail: string }) =>
dispatch("select", path);
const onClick = () => {
expanded = !expanded;
tree = fetchTree(prefix).then(tree => {
if (expanded) return tree;
});
};
</script>
<style>
.folder {
display: flex;
cursor: pointer;
padding: 0.25rem;
margin: 0.125rem 0;
color: var(--color-foreground-6);
user-select: none;
line-height: 1.5rem;
white-space: nowrap;
}
.folder:hover {
background-color: var(--color-foreground-1);
border-radius: var(--border-radius-small);
}
.folder-name {
margin-left: 0.25rem;
color: var(--color-secondary-6);
}
.container {
padding-left: 0.5rem;
margin: 0;
}
.loading {
display: inline-block;
padding: 0.5rem 0;
}
</style>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="folder" on:click={onClick}>
<span class="folder-name">{name}/</span>
</div>
<div class="container">
{#if expanded}
{#await tree}
<span class="loading"><Loading small margins /></span>
{:then tree}
{#if tree}
{#each tree.entries as entry (entry.path)}
{#if entry.info.objectType === ObjectType.Tree}
<svelte:self
{fetchTree}
name={entry.info.name}
on:select={onSelectFile}
prefix={`${entry.path}/`}
{loadingPath}
{currentPath} />
{:else}
<File
active={entry.path === currentPath}
loading={entry.path === loadingPath}
name={entry.info.name}
on:click={() => {
onSelectFile({ detail: entry.path });
}} />
{/if}
{/each}
{/if}
{/await}
{/if}
</div>
+207
View File
@@ -0,0 +1,207 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { ProjectRoute } from "@app/lib/router/definitions";
import type { State as IssueState } from "./Issues.svelte";
import type { State as PatchState } from "./Patches.svelte";
import * as issue from "@app/lib/issue";
import * as patch from "@app/lib/patch";
import * as proj from "@app/lib/project";
import * as router from "@app/lib/router";
import Loading from "@app/components/Loading.svelte";
import NotFound from "@app/components/NotFound.svelte";
import { formatSeedId, unreachable } from "@app/lib/utils";
import Header from "./Header.svelte";
import Browser from "./Browser.svelte";
import History from "./History.svelte";
import Commit from "./Commit.svelte";
import Issues from "./Issues.svelte";
import Issue from "./Issue.svelte";
import Patches from "./Patches.svelte";
import Patch from "./Patch.svelte";
import ProjectMeta from "./ProjectMeta.svelte";
import Message from "@app/components/Message.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
export let wallet: Wallet;
export let activeRoute: ProjectRoute;
$: urn = activeRoute.params.urn;
$: peer = activeRoute.params.peer ?? null;
$: seed = activeRoute.params.seed ?? null;
$: profile = activeRoute.params.profile ?? null;
$: searchParams = new URLSearchParams(activeRoute.params.search || "");
$: issueFilter = (searchParams.get("state") as IssueState) || "open";
$: patchFilter = (searchParams.get("state") as PatchState) || "proposed";
const getProject = async (
urn: string,
peer: string | null,
profile: string | null,
seed: string | null,
) => {
const project = await proj.Project.get(urn, peer, profile, seed, wallet);
if (activeRoute.params.route) {
const { revision, path } = proj.parseRoute(
activeRoute.params.route,
project.branches,
);
router.updateProjectRoute(
{
revision,
path,
line: activeRoute.params.line,
hash: activeRoute.params.hash,
route: undefined,
},
{ replace: true },
);
}
if (!activeRoute.params.revision) {
// We need a revision to fetch `getRoot`.
// Don't use router.updateProjectRoute, to avoid changing the URL.
activeRoute.params.revision = project.defaultBranch;
}
return project;
};
// Content can be altered in child components.
$: revision = activeRoute.params.revision || null;
</script>
<style>
main {
width: 100%;
max-width: var(--content-max-width);
min-width: var(--content-min-width);
padding: 4rem 0;
}
main > header {
padding: 0 2rem 0 8rem;
}
main > .message {
padding: 0 2rem 0 8rem;
}
@media (max-width: 960px) {
main > header {
padding-left: 2rem;
}
main > .message {
padding-left: 2rem;
}
main {
padding-top: 2rem;
min-width: 0;
}
}
</style>
<main>
{#await getProject(urn, peer, profile, seed)}
<header>
<Loading center />
</header>
{:then project}
<ProjectMeta {project} {peer} />
{#await project.getRoot(revision)}
<Loading center />
{:then { tree, commit }}
<Header {tree} {commit} {project} {activeRoute} />
{#if activeRoute.params.view.resource === "tree"}
<Browser {project} {commit} {tree} {activeRoute} />
{:else if activeRoute.params.view.resource === "history"}
{#await proj.Project.getCommits( project.urn, project.seed.api, { parent: commit, verified: true }, )}
<Loading center />
{:then history}
<History {project} {history} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "commits"}
{#await project.getCommit(commit)}
<Loading center />
{:then commit}
<Commit {commit} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "issues"}
{#await issue.Issue.getIssues(project.urn, project.seed.api)}
<Loading center />
{:then issues}
<Issues state={issueFilter} {wallet} {issues} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "issue"}
{#await issue.Issue.getIssue(project.urn, activeRoute.params.view.params.issue, project.seed.api)}
<Loading center />
{:then issue}
<Issue {project} {wallet} {issue} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "patches"}
{#await patch.Patch.getPatches(project.urn, project.seed.api)}
<Loading center />
{:then patches}
<Patches {wallet} state={patchFilter} {patches} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "patch"}
{#await patch.Patch.getPatch(project.urn, activeRoute.params.view.params.patch, project.seed.api)}
<Loading center />
{:then patch}
<Patch {project} {wallet} {patch} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else}
{unreachable(activeRoute.params.view)}
{/if}
{:catch e}
<div class="message">
{#if peer}
<Placeholder emoji="🍂">
<span slot="title">
<span class="txt-monospace">{formatSeedId(peer)}</span>
</span>
<span slot="body">
<span style="display: block">
Couldn't load remote source tree.
</span>
<span>{e.message}</span>
</span>
</Placeholder>
{:else}
<Placeholder emoji="🍂">
<span slot="body">
<span style="display: block">Couldn't load source tree.</span>
<span>{e.message}</span>
</span>
</Placeholder>
{/if}
</div>
{/await}
{:catch}
<NotFound title={urn} subtitle="This project was not found." />
{/await}
</main>
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import type * as proj from "@app/lib/project";
import Diagram from "@app/views/projects/Diagram.svelte";
import { groupCommitsByWeek } from "@app/lib/commit";
import type { Host } from "@app/lib/api";
import { Project } from "@app/lib/project";
import { formatCommit } from "@app/lib/utils";
export let project: proj.ProjectInfo;
export let seed: { api: Host };
export let faded = false;
export let compact = false;
const loadCommits = async () => {
const commits = await Project.getActivity(project.urn, seed.api);
return groupCommitsByWeek(commits.activity);
};
</script>
<style>
article {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 1rem;
border: 1px solid var(--color-secondary-5);
border-radius: var(--border-radius-small);
min-width: 36rem;
cursor: pointer;
}
article .right {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
}
article .left {
width: 50%;
}
div .description {
overflow-x: hidden;
overflow-y: hidden;
text-overflow: ellipsis;
}
article.compact {
min-width: 16rem;
height: 9rem;
}
article.compact .left {
width: 100%;
}
article.compact .right {
display: none;
}
article.compact .description {
white-space: nowrap;
}
article.project-faded {
border: 1px dashed var(--color-foreground-4);
cursor: not-allowed;
}
.activity {
width: 100%;
max-width: 14rem;
}
article:hover {
border-color: var(--color-secondary);
background-color: var(--color-secondary-1);
}
article.project-faded:hover {
border-color: var(--color-foreground-5);
}
article .id {
font-size: var(--font-size-regular);
font-weight: var(--font-weight-medium);
margin-bottom: 0.5rem;
}
article .description {
margin-bottom: 0.25rem;
font-size: var(--font-size-tiny);
}
article .stateHash {
color: var(--color-secondary);
font-size: var(--font-size-tiny);
font-family: var(--font-family-monospace);
min-height: 2rem;
display: flex;
align-items: center;
}
article .id {
display: flex;
justify-content: space-between;
}
article .id .urn {
visibility: hidden;
color: var(--color-foreground-5);
font-weight: var(--font-weight-normal);
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
}
article:hover .id .urn {
visibility: visible;
}
@media (max-width: 720px) {
article {
min-width: 0;
}
}
</style>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<article on:click class:project-faded={faded} class:compact>
<div class="left">
<div class="id">
<span class="name">{project.name}</span>
</div>
{#if project.description}
<div class="description">{project.description}</div>
{:else}
<div class="description txt-missing">No description</div>
{/if}
<div class="stateHash">
{#if project.head}
{#if compact}
{formatCommit(project.head)}
{:else}
{project.head}
{/if}
{:else}
<span class="txt-missing">✗ No head</span>
{/if}
</div>
{#if compact}
{#await loadCommits() then points}
<div class="activity">
<Diagram
{points}
strokeWidth={3}
viewBoxHeight={70}
viewBoxWidth={600} />
</div>
{/await}
{/if}
</div>
{#if !compact}
<div class="right">
<div class="id">
<span class="urn layout-desktop">{project.urn}</span>
</div>
{#await loadCommits() then points}
<div class="layout-desktop activity">
<Diagram
{points}
strokeWidth={3}
viewBoxHeight={100}
viewBoxWidth={600} />
</div>
{/await}
</div>
{/if}
</article>