Migrate to the heartwood protocol stack

Signed-off-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
Rūdolfs Ošiņš
2023-03-03 14:23:46 +01:00
parent d1168dc6ff
commit 2bad94457c
59 changed files with 462 additions and 1983 deletions
+4 -10
View File
@@ -14,8 +14,8 @@ jobs:
matrix:
# Disable firefox until this is fixed upstream:
# https://github.com/microsoft/playwright/issues/21145
# browser: [chromium, firefox, visual, heartwood]
browser: [chromium, visual, heartwood]
# browser: [chromium, firefox, visual]
browser: [chromium, visual]
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
@@ -59,19 +59,13 @@ jobs:
- name: Start http-api test server
run: |
if [ ${{ matrix.browser }} = "heartwood" ]; then
mkdir -p tests/artifacts
./scripts/run-httpd-with-fixtures --non-interactive --download 2>&1 | tee tests/artifacts/httpd-${{ matrix.browser }}.log &
else
./scripts/run-http-api-with-fixtures --non-interactive --detach
fi
mkdir -p tests/artifacts;
./scripts/run-httpd-with-fixtures --non-interactive --download 2>&1 | tee tests/artifacts/httpd-${{ matrix.browser }}.log &
- name: Run Playwright tests
run: |
if [ ${{ matrix.browser }} = "visual" ] && [ ${{ github.ref }} = "refs/heads/master" ]; then
npm run test:e2e -- --project ${{ matrix.browser }} --update-snapshots;
elif [ ${{ matrix.browser }} = "heartwood" ]; then
HEARTWOOD=true npm run test:e2e -- --project chromium;
else
npm run test:e2e -- --project ${{ matrix.browser }};
fi
+71 -45
View File
@@ -1,30 +1,29 @@
#!/usr/bin/env bash
killall radicle-node
killall git-daemon
set -euo pipefail
function cleanup {
docker kill radicle-git-server-test
}
trap cleanup EXIT
PASSPHRASE=asdf
REV=40bdc662b2d48cbfaa87182b21457ef8f861b04a
export RAD_PASSPHRASE=asdf
REPO_ROOT=$(git rev-parse --show-toplevel)
ID=$(echo $RANDOM | md5sum | head -c 8)
BASE_PATH=$REPO_ROOT/tests/tmp/create-seed-fixture-$ID
ID=$(echo $RANDOM | md5sum | head -c 4)
BASE_PATH=$REPO_ROOT/tests/tmp/seed-fixture-$ID
TEST_REPO_ARCHIVE=$REPO_ROOT/tests/fixtures/repos/source-browsing.tar.bz2
TEST_REPO_NAME=source-browsing
TEST_REPO_PATH=$BASE_PATH/repos/$TEST_REPO_NAME
PALM_RAD_HOME=$BASE_PATH/seeds/palm
PALM_CHECKOUT=$BASE_PATH/checkout/palm
ALICE_RAD_HOME=$BASE_PATH/peers/alice
ALICE_CHECKOUT=$BASE_PATH/checkout/alice
BOB_RAD_HOME=$BASE_PATH/peers/bob
BOB_CHECKOUT=$BASE_PATH/checkout/bob
mkdir -p $PALM_RAD_HOME
mkdir -p $PALM_CHECKOUT
mkdir -p $ALICE_RAD_HOME
mkdir -p $ALICE_CHECKOUT
mkdir -p $BOB_RAD_HOME
@@ -33,62 +32,89 @@ mkdir -p $TEST_REPO_PATH
tar -xf $TEST_REPO_ARCHIVE -C $TEST_REPO_PATH
RAD_HOME=$PALM_RAD_HOME rad auth --init --name palm --passphrase $PASSPHRASE
### PALM NODE ###
docker run \
--detach \
--init \
--publish 8778:8778 \
--rm \
--env RAD_HOME=/app/radicle \
--name radicle-git-server-test \
--volume $PALM_RAD_HOME:/app/radicle \
"gcr.io/radicle-services/git-server:$REV" \
--passphrase $PASSPHRASE \
--allow-unauthorized-keys
eval $(ssh-agent)
# git-server takes a while to copy commit hooks to the monorepo
sleep 10
export RAD_HOME=$PALM_RAD_HOME
export RAD_SEED=ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffee
GIT_AUTHOR_NAME="Alice Liddell"
GIT_AUTHOR_EMAIL="alice@radicle.xyz"
GIT_COMMITTER_NAME="Alice Liddell"
GIT_COMMITTER_EMAIL="alice@radicle.xyz"
rad auth
radicle-node --listen 0.0.0.0:3446 --git-daemon 0.0.0.0:4446 &
RAD_HOME=$ALICE_RAD_HOME rad auth --init --name alice --passphrase $PASSPHRASE
### ALICE ###
export RAD_HOME=$ALICE_RAD_HOME
export RAD_SEED=ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
export GIT_AUTHOR_NAME="Alice Liddell"
export GIT_AUTHOR_EMAIL="alice@radicle.xyz"
export GIT_COMMITTER_NAME="Alice Liddell"
export GIT_COMMITTER_EMAIL="alice@radicle.xyz"
rad auth
cd $ALICE_CHECKOUT
git clone $TEST_REPO_PATH
cd $TEST_REPO_NAME
git checkout feature/branch
git checkout orphaned-branch
git checkout main
rad init --name "source-browsing" \
--description "Git repository for source browsing tests" \
--default-branch "main" \
--no-confirm
RAD_HOME=$ALICE_RAD_HOME rad init --name "source-browsing" --description "Git repository for source browsing tests" --default-branch "main" --no-confirm
git checkout feature/branch
git push rad
sleep 10
RAD_HOME=$ALICE_RAD_HOME rad push --seed 0.0.0.0:8778 --all --sync
PROJECT_ID=$(rad .)
git checkout orphaned-branch
git push rad
radicle-node --listen 0.0.0.0:3444 --git-daemon 0.0.0.0:4444 \
--connect z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8@0.0.0.0:3446 &
GIT_AUTHOR_NAME="Bob Belcher"
GIT_AUTHOR_EMAIL="bob@radicle.xyz"
GIT_COMMITTER_NAME="Bob Belcher"
GIT_COMMITTER_EMAIL="bob@radicle.xyz"
sleep 1
RAD_HOME=$BOB_RAD_HOME rad auth --init --name bob --passphrase $PASSPHRASE
### PALM CLONE ###
cd $PALM_CHECKOUT
export RAD_HOME=$PALM_RAD_HOME
rad clone rad:zKtT7DmF9H34KkvcKj9PHW19WzjT
### BOB ###
export GIT_AUTHOR_NAME="Bob Belcher"
export GIT_AUTHOR_EMAIL="bob@radicle.xyz"
export GIT_COMMITTER_NAME="Bob Belcher"
export GIT_COMMITTER_EMAIL="bob@radicle.xyz"
export GIT_COMMITTER_DATE="Mon Dec 21 14:00 2022 +0100"
export RAD_HOME=$BOB_RAD_HOME
export RAD_SEED=fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe
rad auth
rad auth
radicle-node --listen 0.0.0.0:3445 --git-daemon 0.0.0.0:4445 \
--connect z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8@0.0.0.0:3446 &
sleep 2
cd $BOB_CHECKOUT
RAD_HOME=$BOB_RAD_HOME rad clone $PROJECT_ID --seed 0.0.0.0:8778 --no-confirm
rad clone rad:zKtT7DmF9H34KkvcKj9PHW19WzjT
cd $TEST_REPO_NAME
echo "Updated readme" > README.md
git add README.md
git commit --message "Update readme" --date "Mon Nov 21 14:00 2022 +0100"
RAD_HOME=$BOB_RAD_HOME rad push --seed 0.0.0.0:8778
RAD_HOME=$BOB_RAD_HOME rad sync --seed 0.0.0.0:8778 --self
RAD_HOME=$BOB_RAD_HOME rad sync --seed 0.0.0.0:8778
git commit --message "Update readme" --date "$GIT_COMMITTER_DATE"
git push rad
### WAIT FOR SYNC WITH PALM ###
sleep 2
cd $BASE_PATH
tar -cjf palm.tar.bz2 --exclude "post-receive" --exclude "pre-receive" -C $PALM_RAD_HOME .
killall radicle-node
killall git-daemon
-120
View File
@@ -1,120 +0,0 @@
#!/usr/bin/env bash
killall radicle-node
killall git-daemon
set -euo pipefail
export RAD_PASSPHRASE=asdf
REPO_ROOT=$(git rev-parse --show-toplevel)
ID=$(echo $RANDOM | md5sum | head -c 4)
BASE_PATH=$REPO_ROOT/tests/tmp/heartwood-$ID
TEST_REPO_ARCHIVE=$REPO_ROOT/tests/fixtures/repos/source-browsing.tar.bz2
TEST_REPO_NAME=source-browsing
TEST_REPO_PATH=$BASE_PATH/repos/$TEST_REPO_NAME
PALM_RAD_HOME=$BASE_PATH/seeds/palm
PALM_CHECKOUT=$BASE_PATH/checkout/palm
ALICE_RAD_HOME=$BASE_PATH/peers/alice
ALICE_CHECKOUT=$BASE_PATH/checkout/alice
BOB_RAD_HOME=$BASE_PATH/peers/bob
BOB_CHECKOUT=$BASE_PATH/checkout/bob
mkdir -p $PALM_RAD_HOME
mkdir -p $PALM_CHECKOUT
mkdir -p $ALICE_RAD_HOME
mkdir -p $ALICE_CHECKOUT
mkdir -p $BOB_RAD_HOME
mkdir -p $BOB_CHECKOUT
mkdir -p $TEST_REPO_PATH
tar -xf $TEST_REPO_ARCHIVE -C $TEST_REPO_PATH
### PALM NODE ###
eval $(ssh-agent)
export RAD_HOME=$PALM_RAD_HOME
export RAD_SEED=ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffee
rad auth
radicle-node --listen 0.0.0.0:3446 --git-daemon 0.0.0.0:4446 &
### ALICE ###
export RAD_HOME=$ALICE_RAD_HOME
export RAD_SEED=ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
export GIT_AUTHOR_NAME="Alice Liddell"
export GIT_AUTHOR_EMAIL="alice@radicle.xyz"
export GIT_COMMITTER_NAME="Alice Liddell"
export GIT_COMMITTER_EMAIL="alice@radicle.xyz"
rad auth
cd $ALICE_CHECKOUT
git clone $TEST_REPO_PATH
cd $TEST_REPO_NAME
git checkout main
rad init --name "source-browsing" \
--description "Git repository for source browsing tests" \
--default-branch "main" \
--no-confirm
git checkout feature/branch
git push rad
git checkout orphaned-branch
git push rad
radicle-node --listen 0.0.0.0:3444 --git-daemon 0.0.0.0:4444 \
--connect z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8@0.0.0.0:3446 &
sleep 1
### PALM CLONE ###
cd $PALM_CHECKOUT
export RAD_HOME=$PALM_RAD_HOME
rad clone rad:zKtT7DmF9H34KkvcKj9PHW19WzjT
### BOB ###
export GIT_AUTHOR_NAME="Bob Belcher"
export GIT_AUTHOR_EMAIL="bob@radicle.xyz"
export GIT_COMMITTER_NAME="Bob Belcher"
export GIT_COMMITTER_EMAIL="bob@radicle.xyz"
export GIT_COMMITTER_DATE="Mon Dec 21 14:00 2022 +0100"
export RAD_HOME=$BOB_RAD_HOME
export RAD_SEED=fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe
rad auth
rad auth
radicle-node --listen 0.0.0.0:3445 --git-daemon 0.0.0.0:4445 \
--connect z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8@0.0.0.0:3446 &
sleep 2
cd $BOB_CHECKOUT
rad clone rad:zKtT7DmF9H34KkvcKj9PHW19WzjT
cd $TEST_REPO_NAME
echo "Updated readme" > README.md
git add README.md
git commit --message "Update readme" --date "$GIT_COMMITTER_DATE"
git push rad
### WAIT FOR SYNC WITH PALM ###
sleep 2
cd $BASE_PATH
tar -cjf palm.tar.bz2 --exclude "post-receive" --exclude "pre-receive" -C $PALM_RAD_HOME .
killall radicle-node
killall git-daemon
-128
View File
@@ -1,128 +0,0 @@
#!/bin/sh
set -e
REV=40bdc662b2d48cbfaa87182b21457ef8f861b04a
REPO_ROOT=$(git rev-parse --show-toplevel)
FIXTURE=$REPO_ROOT/tests/fixtures/seeds/palm.tar.bz2
WORKSPACE=$REPO_ROOT/tests/tmp/palm
PASSPHRASE=asdf
CONTAINER_NAME=radicle-http-api-with-fixtures
HTTP_API_BINARY=radicle-http-api
show_usage() {
echo
echo "Starts a http-api backend with test fixtures."
echo
echo "USAGE:"
echo " run-http-api-with-fixtures [-b|d|h|n]"
echo
echo "OPTIONS:"
echo " -b --binary Use a ${HTTP_API_BINARY} binary that is in PATH to avoid using Docker."
echo " -d --detach Daemonize the docker process."
echo " -h --help Print this Help."
echo " -n --non-interactive Run in non-interactive mode, no user prompts."
echo
}
prompt_workspace_removal() {
echo "This will irrevocably destroy the following directories:"
echo
echo $WORKSPACE
echo
read -r -p "Are you sure you want to continue? [yes/no]: " confirm
case "$confirm" in
[yY][eE][sS])
rm -rf $WORKSPACE
echo "Done"
;;
*)
echo "Ok, I won't touch your data."
exit
;;
esac
}
prepare_workspace() {
echo
echo "Unpacking fixture $FIXTURE"
mkdir -p $WORKSPACE
tar -xf $FIXTURE -C $WORKSPACE
}
run_docker() {
echo "Starting docker at container $CONTAINER_NAME at $REV"
echo " http-api --root $WORKSPACE --passphrase $PASSPHRASE"
echo
exec docker run \
--init \
--publish 8777:8777 \
--rm \
--name $CONTAINER_NAME \
--volume $WORKSPACE:/app/radicle \
"$@" \
"gcr.io/radicle-services/http-api:$REV" \
--passphrase $PASSPHRASE
}
run_binary() {
if ! [ -x "$(command -v $HTTP_API_BINARY)" ]; then
echo
echo "Couldn't find the $HTTP_API_BINARY binary in your PATH."
echo "You can compile it from source:"
echo " 👉 https://github.com/radicle-dev/radicle-client-services/tree/${REV}/http-api"
echo
exit 1
fi
echo
echo "Starting $HTTP_API_BINARY"
echo " $HTTP_API_BINARY --listen 0.0.0.0:8777 --root ${WORKSPACE} --passphrase $PASSPHRASE"
echo
$HTTP_API_BINARY --listen 0.0.0.0:8777 --root $WORKSPACE --passphrase $PASSPHRASE
}
BINARY=false
NON_INTERACTIVE=false
DETACH=false
while [ $# -ne 0 ]; do
case "$1" in
--binary | -b)
BINARY=true
;;
--detach | -d)
DETACH=true
;;
--non-interactive | -n)
NON_INTERACTIVE=true
;;
*)
show_usage
exit
;;
esac
shift
done
if [ "$NON_INTERACTIVE" = true ]; then
rm -rf $WORKSPACE
prepare_workspace
else
prompt_workspace_removal
prepare_workspace
fi
if [ "$BINARY" = true ]; then
run_binary
else
if [ "$DETACH" = true ]; then
run_docker --detach
else
run_docker
fi
fi
+1 -1
View File
@@ -4,7 +4,7 @@ set -e
REV=f1de61ad8897a845f2be49e3e2b2951bc678b678
REPO_ROOT=$(git rev-parse --show-toplevel)
FIXTURE=$REPO_ROOT/tests/fixtures/seeds/palm-heartwood.tar.bz2
FIXTURE=$REPO_ROOT/tests/fixtures/seeds/palm.tar.bz2
WORKSPACE=$REPO_ROOT/tests/tmp/palm
PASSPHRASE=asdf
BINARY_PATH="$REPO_ROOT/tests/tmp"
+2 -2
View File
@@ -13,7 +13,7 @@
import Home from "@app/views/home/Index.svelte";
import Session from "@app/views/session/Index.svelte";
import Projects from "@app/views/projects/View.svelte";
import Seeds from "@app/views/seeds/Routes.svelte";
import Seeds from "@app/views/seeds/View.svelte";
const activeRouteStore = router.activeRouteStore;
@@ -60,7 +60,7 @@
{#if $activeRouteStore.resource === "home"}
<Home />
{:else if $activeRouteStore.resource === "seeds"}
<Seeds host={$activeRouteStore.params.host} />
<Seeds hostAndPort={$activeRouteStore.params.host} />
{:else if $activeRouteStore.resource === "session"}
<Session activeRoute={$activeRouteStore} />
{:else if $activeRouteStore.resource === "projects"}
+4 -9
View File
@@ -3,17 +3,13 @@
import { closeFocused } from "@app/components/Floating.svelte";
import { sessionStore, disconnect } from "@app/lib/session";
import { toClipboard } from "@app/lib/utils";
import { toClipboard, formatNodeId } from "@app/lib/utils";
import Avatar from "@app/components/Comment/Avatar.svelte";
import Button from "@app/components/Button.svelte";
import Floating from "@app/components/Floating.svelte";
import Icon from "@app/components/Icon.svelte";
function formatId(id: string) {
return id.substring(0, 4) + " – " + id.substring(id.length - 4, id.length);
}
let icon: "clipboard-small" | "checkmark-small" = "clipboard-small";
const restoreIcon = debounce(() => {
@@ -43,7 +39,7 @@
position: absolute;
right: 5rem;
top: 5rem;
width: 16.5rem;
width: 15rem;
}
.info {
align-items: flex-start;
@@ -118,7 +114,6 @@
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
user-select: none;
width: 11rem;
word-break: break-all;
}
.disconnect {
@@ -171,7 +166,7 @@
title={$sessionStore.publicKey} />
</div>
<div class="user-id txt-small">
{formatId($sessionStore.publicKey)}
{formatNodeId($sessionStore.publicKey)}
</div>
</div>
{:else}
@@ -214,7 +209,7 @@
}
}}>
<div class="id">
{$sessionStore.publicKey}
{formatNodeId($sessionStore.publicKey)}
</div>
<div class="id-clipboard">
<Icon name={icon} />
+2 -2
View File
@@ -4,7 +4,7 @@
import * as modal from "@app/lib/modal";
import Link from "@app/components/Link.svelte";
import Modal from "@app/components/Modal.svelte";
import { formatRadicleId, getSeedEmoji } from "@app/lib/utils";
import { formatRepositoryId, getSeedEmoji } from "@app/lib/utils";
export let query: string;
export let results: ProjectResult[];
@@ -48,7 +48,7 @@
{getSeedEmoji(project.seed.host)}&nbsp;{project.info.name}
</span>
<span class="id">
&nbsp;{formatRadicleId(project.info.id)}
&nbsp;{formatRepositoryId(project.info.id)}
</span>
</span>
</Link>
+3 -11
View File
@@ -1,11 +1,7 @@
<script lang="ts">
import type { Author } from "@app/lib/cobs";
import {
formatNodeId,
formatRadicleId,
formatTimestamp,
} from "@app/lib/utils";
import { formatNodeId, formatTimestamp } from "@app/lib/utils";
export let author: Author;
export let timestamp: number;
@@ -37,13 +33,9 @@
</style>
<span class="authorship txt-tiny">
<span class="id highlight layout-desktop">
{window.HEARTWOOD ? formatNodeId(author.id) : formatRadicleId(author.id)}
</span>
<span class="id highlight layout-desktop">{formatNodeId(author.id)}</span>
<span class="id highlight layout-mobile">
{window.HEARTWOOD
? formatNodeId(author.id).replace("did:key:", "")
: formatRadicleId(author.id)}
{formatNodeId(author.id).replace("did:key:", "")}
</span>
<span class="caption">&nbsp;{caption}&nbsp;</span>
<span class="date">
+3 -9
View File
@@ -1,11 +1,10 @@
<script lang="ts">
import { createIcon } from "@app/lib/blockies";
import { isPeerId, isRadicleId } from "@app/lib/utils";
import { isNodeId } from "@app/lib/utils";
export let title: string;
export let source: string;
export let inline = false;
export let grayscale = false;
function handleMissingFile() {
console.warn("Not able to locate", source);
@@ -23,10 +22,9 @@
return avatar.toDataURL();
}
if (isRadicleId(source) || isPeerId(source)) {
if (isNodeId(source)) {
source = createContainer(source);
}
grayscale = isPeerId(title) || isRadicleId(title);
</script>
<style>
@@ -41,9 +39,6 @@
background-size: cover;
background-repeat: no-repeat;
}
.grayscale {
filter: grayscale();
}
.inline {
display: inline-block !important;
width: 1rem;
@@ -58,5 +53,4 @@
class="avatar"
alt="avatar"
on:error={handleMissingFile}
class:inline
class:grayscale />
class:inline />
+18 -37
View File
@@ -1,56 +1,37 @@
{
"reactions": ["👍", "👎", "😄", "🎉", "🙁", "🚀", "👀"],
"seeds": {
"defaultHttpdPort": 443,
"defaultHttpdScheme": "https",
"defaultNodePort": 8776,
"pinned": [
{
"host": "willow.radicle.garden",
"emoji": "🪵"
},
{ "host": "pine.radicle.garden", "emoji": "🌲" },
{ "host": "maple.radicle.garden", "emoji": "🍁" }
"host": "seed.radicle.xyz",
"emoji": "🌱"
}
]
},
"projects": {
"pinned": [
{
"name": "solmate",
"id": "rad:git:hnrkbgczcfh9ycjtdfynmqyg478bqrso1hnty",
"seed": "willow.radicle.garden"
"name": "radicle-interface",
"id": "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5",
"seed": "seed.radicle.xyz"
},
{
"name": "openzeppelin-contracts",
"id": "rad:git:hnrkbx9xzjg8tf9hj9uh5qdxwuyjddsxyaw6o",
"seed": "willow.radicle.garden"
"name": "heartwood",
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"seed": "seed.radicle.xyz"
},
{
"name": "haskell-language-server",
"id": "rad:git:hnrkf1jaje1e93rua64zgphg7zb14qo9zexgy",
"seed": "pine.radicle.garden"
"name": "rips",
"id": "rad:z3trNYnLWS11cJWC6BbxDs5niGo82",
"seed": "seed.radicle.xyz"
},
{
"name": "rx",
"id": "rad:git:hnrk8aoks6w3tdbcwt9dqnor9aqy8tjha3k5o",
"seed": "maple.radicle.garden"
},
{
"name": "astrolabe",
"id": "rad:git:hnrkrwewct7pb1biwmf59qa3rzod8mauundzy",
"seed": "willow.radicle.garden"
},
{
"name": "svelte-dappkit",
"id": "rad:git:hnrkgcm1e588ewy7iiq4abrrgtf5ioq577eno",
"seed": "maple.radicle.garden"
},
{
"name": "tmyxer",
"id": "rad:git:hnrkp5jgfxwxrffjsp148fyzdc457pa9ja38y",
"seed": "pine.radicle.garden"
},
{
"name": "hexade",
"id": "rad:git:hnrkxj511yr48oo3usrt4jzfoucj88zphpryo",
"seed": "pine.radicle.garden"
"name": "radicle-git",
"id": "rad:zMKWcgFLjUSVBBVmxbcMzzokRxub",
"seed": "seed.radicle.xyz"
}
]
}
-2
View File
@@ -10,8 +10,6 @@ declare global {
PLAYWRIGHT: boolean;
HASH_ROUTING: boolean;
HEARTWOOD: boolean;
// APP_CONFIG is set from within Playwright tests at runtime.
// To better understand how it works together, have a look at:
// tests/support/fixtures.ts
+5 -15
View File
@@ -1,26 +1,18 @@
import { defaultSeedPort } from "@app/lib/seed";
import { isLocal } from "@app/lib/utils";
export interface Host {
host: string;
port: number | null;
port: number;
scheme: string;
}
export class Request {
path: string;
base: string;
protocol: string;
port: number;
constructor(path: string, api: Host) {
this.port = api.port || defaultSeedPort;
if (window.HEARTWOOD) {
this.base = api.host + "/api";
} else {
this.base = api.host;
}
this.port = api.port;
this.base = `${api.scheme}://${api.host}/api`;
this.path = path.startsWith("/") ? path.slice(1) : path;
this.protocol = isLocal(api.host) ? "http://" : "https://";
}
async get(
@@ -97,9 +89,7 @@ export class Request {
// Creates a URL with an eventual query string and port.
private createUrl(search?: string): string {
const baseUrl = this.path
? `${this.protocol}${this.base}/v1/${this.path}`
: `${this.protocol}${this.base}`;
const baseUrl = this.path ? `${this.base}/v1/${this.path}` : this.base;
const url = new URL(search ? `${baseUrl}?${search}` : baseUrl);
url.port = String(this.port);
-1
View File
@@ -5,7 +5,6 @@ export interface Comment<R = null> {
body: string;
reactions: Record<string, number>;
timestamp: number;
replies: R; // TODO: Remove for Heartwood migration
replyTo: R;
}
+6
View File
@@ -3,6 +3,9 @@ import configJson from "@app/config.json";
export interface Config {
reactions: string[];
seeds: {
defaultHttpdPort: number;
defaultNodePort: number;
defaultHttpdScheme: string;
pinned: { host: string; emoji: string }[];
};
projects: {
@@ -19,6 +22,9 @@ function getConfig(): Config {
return {
reactions: [],
seeds: {
defaultHttpdPort: 8080,
defaultHttpdScheme: "http",
defaultNodePort: 8776,
pinned: [],
},
projects: { pinned: [] },
+4 -19
View File
@@ -14,7 +14,6 @@ export interface IIssue {
author: Author;
title: string;
state: State;
comment: Comment; // TODO: Remove this after we have migrated to Heartwood.
discussion: Thread[];
tags: string[];
assignees: string[];
@@ -35,7 +34,6 @@ export interface Comment<R = null> {
body: string;
reactions: Record<string, number>;
timestamp: number;
replies: R; // TODO: Remove this after we have migrated to Heartwood.
replyTo: R;
}
@@ -59,7 +57,6 @@ export class Issue {
author: Author;
title: string;
state: State;
comment: Comment; // TODO: Remove this after we have migrated to Heartwood.
discussion: Thread[];
tags: string[];
assignees: string[];
@@ -70,29 +67,17 @@ export class Issue {
this.author = issue.author;
this.title = issue.title;
this.state = issue.state;
this.comment = issue.comment; // TODO: Remove this after we have migrated to Heartwood.
this.discussion = issue.discussion;
this.tags = issue.tags;
this.assignees = issue.assignees;
if (window.HEARTWOOD) {
this.timestamp = issue.discussion[0].timestamp;
} else {
this.timestamp = issue.timestamp;
}
this.timestamp = issue.discussion[0].timestamp;
}
// Counts the amount of comments and replies in a discussion
countComments(): number {
if (window.HEARTWOOD) {
return this.discussion.reduce(acc => {
return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
}, 0);
} else {
return this.discussion.reduce((acc, comment) => {
if (comment.replies) return acc + comment.replies.length + 1; // We add all replies and 1 forathe comment in this loop.
return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
}, 0);
}
return this.discussion.reduce(acc => {
return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
}, 0);
}
static async createIssue(
-251
View File
@@ -1,251 +0,0 @@
import type { Author, PeerInfo } from "@app/lib/cobs";
import type { Comment, Thread } from "@app/lib/issue";
import type { Commit } from "@app/lib/commit";
import type { Diff, DiffStats } from "@app/lib/diff";
import type { Host } from "@app/lib/api";
import type { PeerId, Id } from "@app/lib/project";
import { Request } from "@app/lib/api";
export interface IPatch {
id: string;
author: Author;
title: string;
state: string;
target: string;
labels: string[];
revisions: Revision[];
timestamp: number;
}
export enum PatchTab {
Timeline = "timeline",
Diff = "diff",
}
export interface Revision {
id: string;
peer: PeerId;
base: string;
oid: string;
comment: Comment;
discussion: Thread[];
reviews: Record<Id, Review>;
merges: Merge[];
changeset: {
diff: Diff;
commits: Commit[];
stats: DiffStats;
} | null;
timestamp: number;
}
export interface Review {
author: Author;
verdict: Verdict | null;
comment: Thread;
inline: CodeComment[];
timestamp: number;
}
export type Verdict = "accept" | "reject";
export interface CodeComment {
location: CodeLocation;
comment: Comment;
}
export interface CodeLocation {
lines: number;
commit: string;
blob: string;
}
export interface Merge {
peer: PeerInfo;
commit: string;
timestamp: number;
}
export function groupPatches(patches: Patch[]) {
return patches.reduce(
(acc: { [state: string]: Patch[] }, patch) => {
acc[patch.state].push(patch);
return acc;
},
{ proposed: [] as Patch[], draft: [] as Patch[], archived: [] as Patch[] },
);
}
export class Patch implements IPatch {
id: string;
author: Author;
title: string;
state: string;
target: string;
labels: string[];
revisions: Revision[];
timestamp: number;
constructor(patch: IPatch) {
this.id = patch.id;
this.author = patch.author;
this.title = patch.title;
this.state = patch.state;
this.target = patch.target;
this.labels = patch.labels;
this.revisions = patch.revisions;
this.timestamp = patch.timestamp;
}
// Counts the amount of comments and replies in a discussion
countComments(rev: number): number {
if (window.HEARTWOOD) {
return this.revisions[rev].discussion.reduce(acc => {
return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
}, 0);
} else {
return this.revisions[rev].discussion.reduce((acc, comment) => {
if (comment.replies) return acc + comment.replies.length + 1; // We add all replies and 1 for each comment in this loop.
return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
}, 0);
}
}
createTimeline(rev: number) {
const timeline: TimelineElement[] = [];
const comment: TimelineElement = {
type: TimelineType.Comment,
timestamp: this.revisions[rev].comment.timestamp,
inner: this.revisions[rev].comment,
};
const discussions = this.revisions[rev].discussion.map(
(comment): TimelineElement => {
return {
type: TimelineType.Thread,
timestamp: comment.timestamp,
inner: comment,
};
},
);
const reviews = Object.entries(this.revisions[rev].reviews).map(
([, review]): TimelineElement => {
return {
type: TimelineType.Review,
timestamp: review.timestamp,
inner: review,
};
},
);
const merges = this.revisions[rev].merges.map((merge): TimelineElement => {
return {
type: TimelineType.Merge,
timestamp: merge.timestamp,
inner: merge,
};
});
timeline.push(comment, ...discussions, ...merges, ...reviews);
return timeline.sort((a, b) => a.timestamp - b.timestamp);
}
static async getPatches(id: string, host: Host): Promise<Patch[]> {
const response = await new Request(`projects/${id}/patches`, host).get();
for (const patch of response) {
patch.author["id"] = patch.author["urn"];
delete patch.author["urn"];
}
return response.map((patch: IPatch) => new Patch(patch));
}
static async getPatch(id: string, patch: string, host: Host): Promise<Patch> {
const response = await new Request(
`projects/${id}/patches/${patch}`,
host,
).get();
response.author["id"] = response.author["urn"];
delete response.author["urn"];
for (const revision of response.revisions) {
if (revision.changeset?.diff) {
revision.changeset.diff["added"] = revision.changeset.diff["created"];
delete revision.changeset.diff["created"];
revision.comment.author["id"] = revision.comment.author["urn"];
delete revision.comment.author["urn"];
revision.changeset.stats["insertions"] =
revision.changeset.stats["additions"];
delete revision.changeset.stats["additions"];
for (const kind of ["added", "deleted", "modified"]) {
for (const file of revision.changeset.diff[kind]) {
for (const hunk of file.diff.hunks) {
for (const line of hunk.lines) {
if (line["lineNumOld"]) {
line["lineNoOld"] = line["lineNumOld"];
delete line["lineNumOld"];
}
if (line["lineNumNew"]) {
line["lineNoNew"] = line["lineNumNew"];
delete line["lineNumNew"];
}
if (line["lineNum"]) {
line["lineNo"] = line["lineNum"];
delete line["lineNum"];
}
}
}
}
}
}
}
return new Patch(response);
}
}
export const formatVerdict = (verdict: string | null): string => {
switch (verdict) {
case "accept":
return "approved this revision";
case "reject":
return "rejected this revision";
default:
return "reviewed and left a comment";
}
};
export enum TimelineType {
Comment,
Thread,
Review,
Merge,
}
export type TimelineElement =
| {
type: TimelineType.Thread;
inner: Thread;
timestamp: number;
}
| {
type: TimelineType.Comment;
inner: Comment;
timestamp: number;
}
| {
type: TimelineType.Merge;
inner: Merge;
timestamp: number;
}
| {
type: TimelineType.Review;
inner: Review;
timestamp: number;
};
+18 -152
View File
@@ -2,28 +2,15 @@ import type { Commit, CommitHeader, CommitsHistory } from "@app/lib/commit";
import type { Host } from "@app/lib/api";
import type { ProjectResult } from "@app/lib/search";
import * as utils from "@app/lib/utils";
import { Request } from "@app/lib/api";
import { Seed, defaultSeedPort } from "@app/lib/seed";
import { isFulfilled, isOid, isRadicleId } from "@app/lib/utils";
import { Seed } from "@app/lib/seed";
import { isFulfilled, isOid, isRepositoryId } from "@app/lib/utils";
export type Id = string;
export type PeerId = string;
export type Branches = { [key: string]: string };
export type MaybeBlob = Blob | undefined;
export type MaybeTree = Tree | undefined;
// TODO: Remove this after we have migrated to Heartwood.
export type Delegate =
| {
type: "indirect";
id: Id;
ids: PeerId[];
}
| {
type: "direct";
id: PeerId;
};
// Enumerates the space below the Header component in the projects View component
export enum ProjectContent {
Tree,
@@ -31,8 +18,6 @@ export enum ProjectContent {
Commit,
Issues,
Issue,
Patches,
Patch,
}
export interface ProjectInfo {
@@ -42,7 +27,6 @@ export interface ProjectInfo {
description: string;
defaultBranch: string;
delegates: string[];
remotes: PeerId[]; // TODO: Remove this after we have migrated to Heartwood.
patches: {
proposed: number;
draft: number;
@@ -56,45 +40,30 @@ export interface ProjectInfo {
export interface Tree {
path: string;
info: EntryInfo; // TODO: Remove this after we have migrated to Heartwood.
entries: Array<Entry>;
stats: Stats;
name: string;
kind: Kind;
lastCommit: CommitHeader;
}
// TODO: Remove "TREE" and "BLOB" after we have migrated to Heartwood.
type Kind = "tree" | "blob" | "TREE" | "BLOB";
type Kind = "tree" | "blob";
export interface Stats {
commits: number;
contributors: number;
}
// TODO: Remove this after we have migrated to Heartwood.
export interface EntryInfo {
name: string;
objectType: Kind;
lastCommit: CommitHeader;
}
export interface Entry {
path: string;
info: EntryInfo; // TODO: Remove this after we have migrated to Heartwood.
name: string;
kind: Kind;
lastCommit: CommitHeader;
}
export interface Blob {
binary?: boolean;
html?: boolean;
binary: boolean;
content: string;
path: string;
info: EntryInfo; // TODO: Remove this after we have migrated to Heartwood.
name: string;
kind: Kind;
lastCommit: CommitHeader;
}
@@ -107,7 +76,7 @@ export interface Person {
}
export interface Peer {
id: PeerId;
id: string;
person?: Person;
delegate: boolean;
}
@@ -161,7 +130,6 @@ export class Project implements ProjectInfo {
description: string;
defaultBranch: string;
delegates: string[];
remotes: PeerId[]; // TODO: Remove this after we have migrated to Heartwood.
seed: Seed;
peers: Peer[];
branches: Branches;
@@ -188,7 +156,6 @@ export class Project implements ProjectInfo {
this.description = info.description;
this.defaultBranch = info.defaultBranch;
this.delegates = info.delegates;
this.remotes = info.remotes; // TODO: Remove this after we have migrated to Heartwood.
this.seed = seed;
this.peers = peers;
this.branches = branches;
@@ -211,14 +178,7 @@ export class Project implements ProjectInfo {
}
static async getInfo(nameOrId: string, host: Host): Promise<ProjectInfo> {
if (window.HEARTWOOD) {
return await new Request(`projects/${nameOrId}`, host).get();
} else {
const result = await new Request(`projects/${nameOrId}`, host).get();
result["id"] = result["urn"];
delete result["urn"];
return result;
}
return await new Request(`projects/${nameOrId}`, host).get();
}
static async getProjects(
@@ -232,17 +192,7 @@ export class Project implements ProjectInfo {
"per-page": opts?.perPage,
page: opts?.page,
};
if (window.HEARTWOOD) {
return await new Request("projects", host).get(params);
} else {
const results = await new Request("projects", host).get(params);
results.forEach((result: { [x: string]: any }) => {
result["id"] = result["urn"];
delete result["urn"];
});
return results;
}
return await new Request("projects", host).get(params);
}
static async getDelegateProjects(
@@ -295,20 +245,6 @@ export class Project implements ProjectInfo {
const result = await new Request(`projects/${id}/commits`, host).get(
params,
);
if (!window.HEARTWOOD) {
for (const commit of result.headers) {
commit.commit = commit.header;
delete commit.header;
commit.commit.committer.time = commit.commit.committerTime;
delete commit.commit.committerTime;
commit.commit.id = commit.commit.sha1;
delete commit.commit.sha1;
}
result.commits = result.headers;
delete result.headers;
}
return result;
}
@@ -325,45 +261,6 @@ export class Project implements ProjectInfo {
this.seed.addr,
).get();
if (!window.HEARTWOOD) {
result.commit = result.header;
delete result.header;
result.commit.committer.time = result.commit.committerTime;
delete result.commit.committerTime;
result.commit.id = result.commit.sha1;
delete result.commit.sha1;
result.stats["insertions"] = result.stats["additions"];
delete result.stats["additions"];
result.diff["added"] = result.diff["created"];
delete result.diff["created"];
for (const kind of ["added", "deleted", "modified"]) {
for (const file of result.diff[kind]) {
for (const hunk of file.diff.hunks) {
for (const line of hunk.lines) {
if (line["lineNumOld"]) {
line["lineNoOld"] = line["lineNumOld"];
delete line["lineNumOld"];
}
if (line["lineNumNew"]) {
line["lineNoNew"] = line["lineNumNew"];
delete line["lineNumNew"];
}
if (line["lineNum"]) {
line["lineNo"] = line["lineNum"];
delete line["lineNum"];
}
}
}
}
}
}
return result;
}
@@ -373,17 +270,6 @@ export class Project implements ProjectInfo {
`projects/${this.id}/tree/${commit}/${path}`,
this.seed.addr,
).get();
if (!window.HEARTWOOD) {
if (result.info.lastCommit) {
result.info.lastCommit.id = result.info.lastCommit.sha1;
delete result.info.lastCommit.sha1;
}
for (const entry of result.entries) {
if (entry.info.lastCommit) {
entry.info.lastCommit.id = entry.info.lastCommit.sha1;
}
}
}
return result;
}
@@ -392,10 +278,6 @@ export class Project implements ProjectInfo {
`projects/${this.id}/blob/${commit}/${path}`,
this.seed.addr,
).get();
if (!window.HEARTWOOD) {
result.info.lastCommit.id = result.info.lastCommit.sha1;
delete result.info.lastCommit.sha1;
}
return result;
}
@@ -404,10 +286,6 @@ export class Project implements ProjectInfo {
`projects/${this.id}/readme/${commit}`,
this.seed.addr,
).get();
if (!window.HEARTWOOD && result.info.lastCommit) {
result.info.lastCommit.id = result.info.lastCommit.sha1;
delete result.info.lastCommit.sha1;
}
return result;
}
@@ -416,31 +294,20 @@ export class Project implements ProjectInfo {
seedHost: string,
peer?: string,
): Promise<Project> {
const [host, port] = seedHost.includes(":")
? seedHost.split(":")
: [seedHost, defaultSeedPort];
let seed: Seed | undefined = undefined;
const seed = await Seed.lookup(host, Number(port)).catch(() => {
throw new Error("Couldn't load project");
});
if (!seed?.valid) {
throw new Error("Couldn't load project: invalid seed");
try {
seed = await Seed.lookup(utils.extractHost(seedHost));
} catch (error) {
throw new Error(`Couldn't load project: ${error}`);
}
const info = await Project.getInfo(id, seed.addr);
id = isRadicleId(id) ? id : info.id;
id = isRepositoryId(id) ? id : info.id;
let peers: Peer[] = [];
if (window.HEARTWOOD) {
peers = await Project.getRemotes(id, seed.addr);
} else {
// Older versions of http-api don't include the ID.
if (!info.id) info.id = id;
peers = info.delegates ? await Project.getRemotes(id, seed.addr) : [];
}
peers = await Project.getRemotes(id, seed.addr);
let remote: Remote = {
heads: info.head ? { [info.defaultBranch]: info.head } : {},
@@ -458,15 +325,14 @@ export class Project implements ProjectInfo {
}
static async getMulti(
projs: { nameOrId: Id; seed: string }[],
projs: { nameOrId: string; seed: Host }[],
): Promise<ProjectResult[]> {
const promises = [];
for (const proj of projs) {
const seed = { host: proj.seed, port: null };
promises.push(
Project.getInfo(proj.nameOrId, seed).then(info => {
return { info, seed };
Project.getInfo(proj.nameOrId, proj.seed).then(info => {
return { info, seed: proj.seed };
}),
);
}
-27
View File
@@ -252,10 +252,6 @@ export function routeToPath(route: Route) {
return `${hostPrefix}/${route.params.id}${peer}/commits${suffix}`;
} else if (route.params.view.resource === "history") {
return `${hostPrefix}/${route.params.id}${peer}/history${suffix}`;
} else if (route.params.view.resource === "patches") {
return `${hostPrefix}/${route.params.id}${peer}/patches${suffix}`;
} else if (route.params.view.resource === "patch") {
return `${hostPrefix}/${route.params.id}${peer}/patches/${route.params.view.params.patch}`;
} else if (
route.params.view.resource === "issues" &&
route.params.view.params?.view.resource === "new"
@@ -325,29 +321,6 @@ function resolveProjectRoute(
search: undefined,
route: segments.join("/"),
};
} else if (content === "patches") {
const patch = segments.shift();
if (patch) {
return {
view: { resource: "patch", params: { patch } },
id,
seed,
peer,
path: undefined,
search: undefined,
revision: undefined,
};
} else {
return {
view: { resource: "patches" },
id,
seed,
peer,
search: sanitizeQueryString(url.search),
path: undefined,
revision: undefined,
};
}
} else if (content === "issues") {
const issueOrAction = segments.shift();
if (issueOrAction === "new") {
+1 -3
View File
@@ -20,9 +20,7 @@ export interface ProjectsParams {
params?: {
view: { resource: "new" };
};
}
| { resource: "patch"; params: { patch: string } }
| { resource: "patches" };
};
seed: string;
hash?: string;
line?: string;
+6 -2
View File
@@ -21,11 +21,15 @@ export async function searchProjectsAndProfiles(
try {
const projectOnSeeds = config.seeds.pinned.map(seed => ({
nameOrId: query,
seed: seed.host,
seed: {
host: seed.host,
port: config.seeds.defaultHttpdPort,
scheme: config.seeds.defaultHttpdScheme,
},
}));
// The query is a radicle project ID.
if (utils.isRadicleId(query)) {
if (utils.isRepositoryId(query)) {
const projects = await Project.getMulti(projectOnSeeds);
if (projects.length === 0) {
+17 -108
View File
@@ -4,103 +4,32 @@ import * as proj from "@app/lib/project";
import { Request } from "@app/lib/api";
import { assert } from "@app/lib/error";
import { getSeedEmoji } from "@app/lib/utils";
import { isDomain } from "@app/lib/utils";
import { config } from "@app/lib/config";
export interface Stats {
projects: { count: number };
users: { count: number };
}
export class InvalidSeed {
valid = false as const;
host?: string;
id?: string;
constructor(host?: string, id?: string) {
this.host = host;
this.id = id;
}
}
export const defaultSeedPort = window.HEARTWOOD ? 8080 : 8777;
export const defaultNodePort = 8776;
// TODO: Remove this after we have migrated to Heartwood.
export const defaultGitPort = 443;
export class Seed {
valid = true as const;
addr: { host: string; port: number | null };
git: { host: string; port: number | null }; // TODO: Remove this after we have migrated to Heartwood.
node: { host: string; id: string; port: number };
addr: Host;
node: Host & { id: string };
version?: string;
emoji: string;
constructor(seed: {
host: string;
id: string;
git?: string | null; // TODO: Remove this after we have migrated to Heartwood.
addr?: string | null;
version?: string | null;
}) {
assert(isDomain(seed.host), `invalid seed host: ${seed.host}`);
if (window.HEARTWOOD) {
assert(/^[a-zA-Z0-9]+$/.test(seed.id), `invalid seed id ${seed.id}`);
} else {
assert(/^[a-z0-9]+$/.test(seed.id), `invalid seed id ${seed.id}`);
}
constructor(seed: { host: Host; id: string; version?: string }) {
assert(/^[a-zA-Z0-9]+$/.test(seed.id), `invalid seed id ${seed.id}`);
let _seed = null;
let _git = null; // TODO: Remove this after we have migrated to Heartwood.
let _seedPort: number | null = defaultSeedPort;
let _gitPort: number | null = defaultGitPort; // TODO: Remove this after we have migrated to Heartwood.
this.emoji = getSeedEmoji(seed.host.host);
if (seed.addr) {
try {
const url = new URL(seed.addr);
_seed = url.hostname;
if (url.port) {
_seedPort = Number(url.port);
} else if (url.protocol === "http:" && url.port === "") {
_seedPort = 80;
}
if (url.protocol === "https:" && url.port === "") {
_seedPort = 443;
} else {
_seedPort = null;
}
} catch {
_seed = seed.addr;
}
assert(isDomain(_seed), `invalid seed host ${_seed}`);
}
if (window.HEARTWOOD) {
if (seed.git) {
try {
const url = new URL(seed.git);
_git = url.hostname;
_gitPort = url.port ? Number(url.port) : null;
} catch {
_git = seed.git;
}
assert(isDomain(_git), `invalid seed git host ${_git}`);
}
}
this.emoji = getSeedEmoji(seed.host);
// The `_seed` being more specific takes
// precedence over the `host`, if available.
_seed = _seed ?? seed.host;
_git = _git ?? seed.host; // TODO: Remove this after we have migrated to Heartwood.
this.addr = { host: _seed, port: _seedPort };
this.git = { host: _git, port: _gitPort }; // TODO: Remove this after we have migrated to Heartwood.
this.node = { host: seed.host, id: seed.id, port: defaultNodePort };
this.addr = seed.host;
this.node = {
host: seed.host.host,
id: seed.id,
port: config.seeds.defaultNodePort,
scheme: seed.host.scheme,
};
if (seed.version) {
this.version = seed.version;
@@ -128,14 +57,7 @@ export class Seed {
? await proj.Project.getDelegateProjects(id, this.addr, { perPage })
: await proj.Project.getProjects(this.addr, { perPage });
if (window.HEARTWOOD) {
return result;
} else {
return result.map((project: proj.ProjectInfo) => ({
...project,
id: project.id,
}));
}
return result;
}
async getStats(): Promise<{
@@ -146,36 +68,23 @@ export class Seed {
}
static async getNode(host: Host): Promise<{ id: string }> {
if (window.HEARTWOOD) {
return new Request("/node", host).get();
} else {
return new Request("/peer", host).get();
}
return new Request("/node", host).get();
}
static async getInfo(host: Host): Promise<{ version: string }> {
return new Request("/", host).get();
}
static async lookup(
hostname: string,
port: number = defaultSeedPort,
): Promise<Seed> {
const host = { host: hostname, port };
static async lookup(host: Host): Promise<Seed> {
const [info, node] = await Promise.all([
Seed.getInfo(host),
Seed.getNode(host),
]);
return new Seed({
host: hostname,
id: node.id,
version: info.version,
addr: `https://${host.host}:${host.port}`,
host,
});
}
static async lookupMulti(hostnames: string[]): Promise<Seed[]> {
return await Promise.all(hostnames.map(h => Seed.lookup(h)));
}
}
+79 -55
View File
@@ -1,8 +1,9 @@
import type { Host } from "@app/lib/api";
import md5 from "md5";
import bs58 from "bs58";
import twemojiModule from "twemoji";
import { assert } from "@app/lib/error";
import { base } from "@app/lib/router";
import { config } from "@app/lib/config";
@@ -43,23 +44,7 @@ export function formatLocationHash(hash: string | null): number | null {
return null;
}
export function formatSeedId(id: string): string {
return id.substring(0, 6) + "…" + id.substring(id.length - 6, id.length);
}
export function formatRadicleId(id: string): string {
assert(isRadicleId(id));
if (window.HEARTWOOD) {
return id.substring(0, 10) + "…" + id.substring(id.length - 6, id.length);
} else {
return id.substring(0, 14) + "…" + id.substring(id.length - 6, id.length);
}
}
// Parses a NID into an object of prefix and pubkey,
// since prefix can be undefined
export function parseNid(
export function parseNodeId(
nid: string,
): { prefix: string; pubkey: string } | undefined {
const match = /^(did:key:)?(z[a-zA-Z0-9]+)$/.exec(nid);
@@ -77,19 +62,52 @@ export function parseNid(
return undefined;
}
// Format a Node Identifier (NID), also represents users or peers
export function formatNodeId(nid: string): string {
const parsedNid = parseNid(nid);
if (parsedNid) {
const { prefix, pubkey } = parsedNid;
const formattedPubKey =
pubkey.substring(0, 6) +
"…" +
pubkey.substring(pubkey.length - 6, pubkey.length);
return `${prefix}${formattedPubKey}`;
export function parseRepositoryId(
rid: string,
): { prefix: string; pubkey: string } | undefined {
const match = /^(rad:)?(z[a-zA-Z0-9]+)$/.exec(rid);
if (match) {
const hex = bs58.decode(match[2].substring(1));
if (hex.byteLength !== 20) {
return undefined;
}
return { prefix: match[1] || "rad:", pubkey: match[2] };
}
return nid;
return undefined;
}
export function isNodeId(input: string): boolean {
return Boolean(parseNodeId(input));
}
export function isRepositoryId(input: string): boolean {
return Boolean(parseRepositoryId(input));
}
export function formatNodeId(id: string): string {
const parsedId = parseNodeId(id);
if (parsedId) {
return truncateId(parsedId.prefix, parsedId.pubkey);
}
return id;
}
export function formatRepositoryId(id: string): string {
const parsedId = parseRepositoryId(id);
if (parsedId) {
return truncateId(parsedId.prefix, parsedId.pubkey);
}
return id;
}
function truncateId(prefix: string, pubkey: string): string {
return `${prefix}${pubkey.substring(0, 6) + "…" + pubkey.slice(-6)}`;
}
export function formatCommit(oid: string): string {
@@ -191,20 +209,6 @@ export const formatTimestamp = (
return new Date(timestamp).toUTCString();
};
// Check whether the input is a Radicle ID.
export function isRadicleId(input: string): boolean {
if (window.HEARTWOOD) {
return /^(did:key:)?[a-zA-Z0-9]+$/.test(input);
} else {
return /^rad:[a-z]+:[a-zA-Z0-9]+$/.test(input);
}
}
// Check whether the input is a Radicle Peer ID.
export function isPeerId(input: string): boolean {
return /^h[a-zA-Z0-9]+$/.test(input);
}
// Check whether the input is a SHA1 commit.
export function isOid(input: string): boolean {
return /^[a-fA-F0-9]{40}$/.test(input);
@@ -221,15 +225,6 @@ export function isFulfilled<T>(
return input.status === "fulfilled";
}
// Parse a Radicle Id.
export function parseRadicleId(id: string): string {
if (window.HEARTWOOD) {
return id.replace(/^rad:/, "");
} else {
return id.replace(/^rad:[a-z]+:/, "");
}
}
// Get amount of days passed between two dates without including the end date
export function getDaysPassed(from: Date, to: Date): number {
return Math.floor((to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000));
@@ -257,8 +252,8 @@ export function isMarkdownPath(path: string): boolean {
return /\.(md|mkd|markdown)$/i.test(path);
}
// Check whether the given input string is a domain, eg. `alt-clients.radicle.xyz.
// Also accepts in dev env 0.0.0.0 as domain
// Check whether the given input string is a domain, eg. seed.radicle.xyz.
// Also accepts in dev env 0.0.0.0 as domain.
export function isDomain(input: string): boolean {
return (
(/^[a-z][a-z0-9.-]+$/.test(input) && /\.[a-z]+$/.test(input)) ||
@@ -268,7 +263,11 @@ export function isDomain(input: string): boolean {
// Check whether the given address is a local host address.
export function isLocal(addr: string): boolean {
return addr === "127.0.0.1" || addr === "0.0.0.0";
return (
addr.startsWith("127.0.0.1") ||
addr.startsWith("0.0.0.0") ||
addr.startsWith("radicle.local")
);
}
// Get the gravatar URL of an email.
@@ -299,3 +298,28 @@ export function twemoji(
className: `txt-emoji`,
});
}
export function extractHost(origin: string): Host {
if (
origin === "radicle.local" ||
origin === "radicle.local:8080" ||
origin === "0.0.0.0" ||
origin === "0.0.0.0:8080" ||
origin === "127.0.0.1" ||
origin === "127.0.0.1:8080"
) {
return { host: "0.0.0.0", port: 8080, scheme: "http" };
} else if (origin.includes(":")) {
return {
host: origin.split(":")[0],
port: Number(origin.split(":")[1]),
scheme: config.seeds.defaultHttpdScheme,
};
} else {
return {
host: origin,
port: config.seeds.defaultHttpdPort,
scheme: config.seeds.defaultHttpdScheme,
};
}
}
+5 -1
View File
@@ -21,7 +21,11 @@
? Project.getMulti(
config.projects.pinned.map(project => ({
nameOrId: project.id,
seed: project.seed,
seed: {
host: project.seed,
port: config.seeds.defaultHttpdPort,
scheme: config.seeds.defaultHttpdScheme,
},
})),
)
: Promise.resolve([]);
+2 -2
View File
@@ -18,7 +18,7 @@
export let line: string | undefined = undefined;
const fileExtension = blob.path.split(".").pop() ?? "";
const lastCommit = window.HEARTWOOD ? blob.lastCommit : blob.info.lastCommit;
const lastCommit = blob.lastCommit;
const parentDir = blob.path
.match(/^.*\/|/)
@@ -215,7 +215,7 @@
<span class="file-name">
<span class="txt-faded">{parentDir}</span>
&#8203;
<span>{window.HEARTWOOD ? blob.name : blob.info.name}</span>
<span>{blob.name}</span>
</span>
<div class="right">
{#if isMarkdown}
+6 -3
View File
@@ -1,14 +1,17 @@
<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";
import { parseRepositoryId } from "@app/lib/utils";
export let seedHost: string;
export let id: string;
export let name: string;
$: radCloneUrl = `rad clone rad://${seedHost}/${utils.parseRadicleId(id)}`;
$: gitCloneUrl = `https://${seedHost}/${utils.parseRadicleId(id)}.git`;
$: radCloneUrl = `rad clone ${id}`;
$: gitCloneUrl = `git clone https://${seedHost}/${
parseRepositoryId(id)?.pubkey ?? id
}.git ${name}`;
</script>
<style>
+1 -1
View File
@@ -74,7 +74,7 @@
</div>
</header>
<Changeset
stats={window.HEARTWOOD ? commit.diff.stats : commit.stats}
stats={commit.diff.stats}
diff={commit.diff}
on:browse={onBrowse} />
</div>
@@ -5,7 +5,6 @@
import Icon from "@app/components/Icon.svelte";
import CommitAuthorship from "./CommitAuthorship.svelte";
import CommitVerifiedBadge from "./CommitVerifiedBadge.svelte";
export let commit: CommitMetadata;
@@ -99,11 +98,6 @@
<CommitAuthorship {commit} noDelegate />
</div>
<div class="column-right">
{#if !window.HEARTWOOD && commit.context.committer}
<div class="layout-desktop">
<CommitVerifiedBadge {commit} />
</div>
{/if}
<span class="hash txt-highlight">{formatCommit(commit.commit.id)}</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
+6 -15
View File
@@ -6,9 +6,10 @@
import * as router from "@app/lib/router";
import BranchSelector from "@app/views/projects/BranchSelector.svelte";
import CloneButton from "@app/views/projects/CloneButton.svelte";
import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
import PeerSelector from "@app/views/projects/PeerSelector.svelte";
import { closeFocused } from "@app/components/Floating.svelte";
import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
import { config } from "@app/lib/config";
export let activeRoute: ProjectRoute;
export let project: Project;
@@ -21,7 +22,7 @@
// Switches between project views.
const toggleContent = (
input: "patches" | "issues" | "history",
input: "issues" | "history",
keepSourceInPath: boolean,
) => {
router.updateProjectRoute({
@@ -51,7 +52,7 @@
};
function goToSeed() {
if (seed.addr.port) {
if (seed.addr.port !== config.seeds.defaultHttpdPort) {
router.push({
resource: "seeds",
params: { host: `${seed.addr.host}:${seed.addr.port}` },
@@ -98,10 +99,8 @@
{revision}
on:branchChanged={event => updateRevision(event.detail)} />
{#if window.HEARTWOOD && seed.addr.host}
<CloneButton seedHost={seed.addr.host} {id} />
{:else if seed.git.host}
<CloneButton seedHost={seed.git.host} {id} />
{#if seed.addr.host}
<CloneButton seedHost={seed.addr.host} {id} name={project.name} />
{/if}
<span>
@@ -131,14 +130,6 @@
<span class="txt-bold">{project.issues.open ?? 0}</span>
issue(s)
</HeaderToggleLabel>
<HeaderToggleLabel
ariaLabel="Patch count"
clickable
active={activeRoute.params.view.resource === "patches"}
on:click={() => toggleContent("patches", false)}>
<span class="txt-bold">{project.patches.proposed ?? 0}</span>
patch(es)
</HeaderToggleLabel>
<HeaderToggleLabel ariaLabel="Contributor count">
<span class="txt-bold">{tree.stats.contributors}</span>
contributor(s)
-16
View File
@@ -93,9 +93,6 @@
color: var(--color-negative);
background-color: var(--color-negative-2);
}
.replies {
margin-left: 2rem;
}
.assignee {
display: flex;
flex-direction: row;
@@ -142,21 +139,8 @@
</header>
<main>
<div class="comments">
{#if !window.HEARTWOOD}
<Comment comment={issue.comment} {getImage} />
{/if}
{#each issue.discussion as comment}
<Comment {comment} {getImage} />
{#if !window.HEARTWOOD}
{#if comment.replies}
<div class="replies">
{#each comment.replies as reply}
<Comment comment={reply} {getImage} />
{/each}
</div>
{/if}
{/if}
{/each}
</div>
<div class="metadata layout-desktop">
+6 -7
View File
@@ -11,7 +11,7 @@
import TextInput from "@app/components/TextInput.svelte";
import Textarea from "@app/components/Textarea.svelte";
import { Issue } from "@app/lib/issue";
import { canonicalize, formatNodeId, parseNid } from "@app/lib/utils";
import { canonicalize, formatNodeId, parseNodeId } from "@app/lib/utils";
import { createEventDispatcher } from "svelte";
export let session: Session;
@@ -29,13 +29,13 @@
let preview: boolean = false;
function handleAddAssignee() {
const nid = parseNid(assignee);
if (nid) {
if (assignees.includes(nid.pubkey)) {
const nodeId = parseNodeId(assignee);
if (nodeId) {
if (assignees.includes(nodeId.pubkey)) {
assigneeCaption = "This user is already assigned";
return;
}
assignees.push(nid.pubkey);
assignees.push(nodeId.pubkey);
assignees = assignees;
assignee = "";
} else {
@@ -185,7 +185,6 @@
author: { id: session.publicKey },
body: issueText,
reactions: {},
replies: null,
replyTo: null,
timestamp: Date.now(),
}}
@@ -245,7 +244,7 @@
on:input={() => (assigneeCaption = undefined)}
placeholder="Assign this issue"
validationMessage={assigneeCaption}
valid={Boolean(parseNid(assignee))} />
valid={Boolean(parseNodeId(assignee))} />
</div>
</div>
<div class="section txt-small">
+2 -1
View File
@@ -8,6 +8,7 @@
import type { Tab } from "@app/components/TabBar.svelte";
import * as router from "@app/lib/router";
import * as utils from "@app/lib/utils";
import { capitalize } from "@app/lib/utils";
import { groupIssues } from "@app/lib/issue";
import { sessionStore } from "@app/lib/session";
@@ -79,7 +80,7 @@
active={state} />
</div>
<HeaderToggleLabel
disabled={!$sessionStore}
disabled={!$sessionStore || !utils.isLocal(project.seed.host)}
on:click={() => {
router.updateProjectRoute({
view: {
-151
View File
@@ -1,151 +0,0 @@
<script lang="ts">
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;
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
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} {project} />
<PatchSideBar {patch} />
</div>
{:else if activeTab === PatchTab.Diff && revision.changeset}
<Changeset
stats={window.HEARTWOOD
? revision.changeset.diff.stats
: revision.changeset.stats}
diff={revision.changeset.diff}
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>
@@ -1,54 +0,0 @@
<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
@@ -1,102 +0,0 @@
<script lang="ts" strictEvents>
import type { Tab } from "@app/components/TabBar.svelte";
import Dropdown from "@app/components/Dropdown.svelte";
import Floating, { closeFocused } from "@app/components/Floating.svelte";
import TabBar from "@app/components/TabBar.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: Tab<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">
<TabBar
{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>
-114
View File
@@ -1,114 +0,0 @@
<script lang="ts">
import type { Patch } from "@app/lib/patch";
import { formatObjectId } from "@app/lib/cobs";
import { twemoji } from "@app/lib/utils";
import Authorship from "@app/components/Authorship.svelte";
export let patch: Patch;
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
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>
@@ -1,73 +0,0 @@
<script lang="ts">
import type { Blob, Project } from "@app/lib/project";
import Authorship from "@app/components/Authorship.svelte";
import Comment from "@app/components/Comment.svelte";
import Review from "@app/views/projects/Patch/Review.svelte";
import { Patch, TimelineType } from "@app/lib/patch";
import { canonicalize } from "@app/lib/utils";
import { formatSeedId } from "@app/lib/utils";
export let patch: Patch;
export let revisionNumber: number;
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={{ id: element.inner.peer.person.id }}
caption={`merged to ${formatSeedId(element.inner.peer.id)}`}
timestamp={element.timestamp} />
</div>
{:else if element.type === TimelineType.Review && element.inner.author.id}
<div class="margin-left">
<Review review={element.inner} {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} {getImage} />
{/key}
</div>
{:else if element.type === TimelineType.Thread}
<div class="margin-left">
<Comment comment={element.inner} {getImage} />
{#if !window.HEARTWOOD}
<div class="replies">
{#each element.inner.replies as comment}
<Comment caption="replied" {comment} {getImage} />
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</div>
-31
View File
@@ -1,31 +0,0 @@
<script lang="ts">
import type { Review } from "@app/lib/patch";
import type { Blob } from "@app/lib/project";
import Authorship from "@app/components/Authorship.svelte";
import Comment from "@app/components/Comment.svelte";
import { formatVerdict } from "@app/lib/patch";
export let review: Review;
export let getImage: (path: string) => Promise<Blob>;
</script>
<style>
div {
margin: 0 0 1rem 3rem;
}
</style>
{#if review.comment.body}
<Comment
{getImage}
comment={review.comment}
caption={formatVerdict(review.verdict)} />
{:else}
<div>
<Authorship
author={review.author}
timestamp={review.timestamp}
caption={formatVerdict(review.verdict)} />
</div>
{/if}
-94
View File
@@ -1,94 +0,0 @@
<script lang="ts" context="module">
export type State = "proposed" | "draft" | "archived";
</script>
<script lang="ts">
import type { Patch } from "@app/lib/patch";
import type { Tab } from "@app/components/TabBar.svelte";
import type { Project } from "@app/lib/project";
import PatchTeaser from "./Patch/PatchTeaser.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import TabBar from "@app/components/TabBar.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 patches: Patch[];
export let project: Project;
let options: Tab<State>[];
const sortedPatches = groupPatches(patches);
$: filteredPatches = sortedPatches[state];
$: options = [
{
value: "proposed",
count: project.patches.proposed,
},
{
value: "draft",
count: project.patches.draft,
},
{
value: "archived",
count: project.patches.archived,
},
];
</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;">
<TabBar
{options}
on:select={e =>
router.updateProjectRoute({
search: `state=${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 {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>
+5 -12
View File
@@ -21,24 +21,17 @@
}[] = [];
function createTitle(p: Peer): string {
const name = p.person?.name ? p.person.name : p.id;
const nodeId = formatNodeId(p.id);
return p.delegate
? `${name} is a delegate of this project`
: `${name} is a peer tracked by this seed`;
? `${nodeId} is a delegate of this project`
: `${nodeId} is a peer tracked by this node`;
}
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,
key: formatNodeId(p.id),
value: p.id,
title: createTitle(p),
badge: p.delegate ? "delegate" : null,
@@ -91,7 +84,7 @@
<Icon name="fork" />
{#if meta}
<span class="peer-id">
{meta.person?.name ?? formatNodeId(meta.id)}
{formatNodeId(meta.id)}
</span>
{#if meta.delegate}
<Badge variant="primary">delegate</Badge>
+8 -8
View File
@@ -1,13 +1,13 @@
<script lang="ts">
import type { PeerId, Project } from "@app/lib/project";
import type { Project } from "@app/lib/project";
import Clipboard from "@app/components/Clipboard.svelte";
import DOMPurify from "dompurify";
import ProjectLink from "@app/components/ProjectLink.svelte";
import { formatSeedId } from "@app/lib/utils";
import { formatNodeId } from "@app/lib/utils";
export let project: Project;
export let peer: PeerId | undefined = undefined;
export let nodeId: string | undefined = undefined;
const linkifyDescription = (text: string) => {
return text.replaceAll(/(https?:\/\/[^\s]+)/g, `<a href="$1">$1</a>`);
@@ -27,7 +27,7 @@
margin: 0 0.5rem;
font-weight: var(--font-weight-normal);
}
.title .peer-id {
.title .node-id {
color: var(--color-foreground-5);
font-weight: var(--font-weight-normal);
display: flex;
@@ -87,11 +87,11 @@
</span>
</ProjectLink>
</span>
{#if peer}
<span class="peer-id">
{#if nodeId}
<span class="node-id">
<span class="divider">/</span>
<span title={peer}>{formatSeedId(peer)}</span>
<Clipboard text={peer} />
<span title={nodeId}>{formatNodeId(nodeId)}</span>
<Clipboard text={nodeId} />
</span>
{/if}
</div>
+3 -3
View File
@@ -18,11 +18,11 @@
</script>
{#each tree.entries as entry (entry.path)}
{#if window.HEARTWOOD ? entry.kind === "tree" : entry.info.objectType === "TREE"}
{#if entry.kind === "tree"}
<Folder
{fetchTree}
{loadingPath}
name={window.HEARTWOOD ? entry.name : entry.info.name}
name={entry.name}
prefix={`${entry.path}/`}
currentPath={path}
on:select={onSelect} />
@@ -30,7 +30,7 @@
<File
active={entry.path === path}
loading={entry.path === loadingPath}
name={window.HEARTWOOD ? entry.name : entry.info.name}
name={entry.name}
on:click={() => onSelect({ detail: entry.path })} />
{/if}
{/each}
+3 -3
View File
@@ -74,10 +74,10 @@
{:then tree}
{#if tree}
{#each tree.entries as entry (entry.path)}
{#if window.HEARTWOOD ? entry.kind === "tree" : entry.info.objectType === "TREE"}
{#if entry.kind === "tree"}
<svelte:self
{fetchTree}
name={window.HEARTWOOD ? entry.name : entry.info.name}
name={entry.name}
on:select={onSelectFile}
prefix={`${entry.path}/`}
{loadingPath}
@@ -86,7 +86,7 @@
<File
active={entry.path === currentPath}
loading={entry.path === loadingPath}
name={window.HEARTWOOD ? entry.name : entry.info.name}
name={entry.name}
on:click={() => {
onSelectFile({ detail: entry.path });
}} />
+1 -26
View File
@@ -1,10 +1,8 @@
<script lang="ts">
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";
@@ -20,8 +18,6 @@
import Issues from "./Issues.svelte";
import Message from "@app/components/Message.svelte";
import NewIssue from "./Issue/New.svelte";
import Patch from "./Patch.svelte";
import Patches from "./Patches.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import ProjectMeta from "./ProjectMeta.svelte";
@@ -33,7 +29,6 @@
$: searchParams = new URLSearchParams(activeRoute.params.search || "");
$: issueFilter = (searchParams.get("state") as IssueState) || "open";
$: patchFilter = (searchParams.get("state") as PatchState) || "proposed";
const getProject = async (id: string, seed: string, peer?: string) => {
const project = await proj.Project.get(id, seed, peer);
@@ -120,7 +115,7 @@
</main>
{:then project}
<main>
<ProjectMeta {project} {peer} />
<ProjectMeta {project} nodeId={peer} />
{#await project.getRoot(revision)}
<Loading center />
{:then { tree, commit }}
@@ -182,26 +177,6 @@
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "patches"}
{#await patch.Patch.getPatches(project.id, project.seed.addr)}
<Loading center />
{:then patches}
<Patches {project} 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.id, activeRoute.params.view.params.patch, project.seed.addr)}
<Loading center />
{:then patch}
<Patch {project} {patch} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else}
{unreachable(activeRoute.params.view)}
{/if}
-11
View File
@@ -1,11 +0,0 @@
<script lang="ts">
import View from "@app/views/seeds/View.svelte";
export let host: string;
</script>
{#if host === "radicle.local"}
<View hostAndPort={"0.0.0.0"} />
{:else}
<View hostAndPort={host} />
{/if}
+20 -16
View File
@@ -1,25 +1,27 @@
<script lang="ts">
import type { Host } from "@app/lib/api";
import type { ProjectInfo } from "@app/lib/project";
import type { Stats } from "@app/lib/seed";
import { Project } from "@app/lib/project";
import { Seed } from "@app/lib/seed";
import { config } from "@app/lib/config";
import {
formatNodeId,
formatSeedHost,
twemoji,
extractHost,
} from "@app/lib/utils";
import Clipboard from "@app/components/Clipboard.svelte";
import Loading from "@app/components/Loading.svelte";
import NotFound from "@app/components/NotFound.svelte";
import Projects from "@app/views/seeds/View/Projects.svelte";
import SeedAddress from "@app/views/seeds/View/SeedAddress.svelte";
import { Project } from "@app/lib/project";
import { Seed, defaultSeedPort } from "@app/lib/seed";
import { formatSeedId, formatSeedHost, twemoji } from "@app/lib/utils";
export let hostAndPort: string;
const [host, port] = hostAndPort.includes(":")
? hostAndPort.split(":")
: [hostAndPort, defaultSeedPort];
const hostName = formatSeedHost(host);
const seedHost: Host = { host, port: Number(port) };
$: seedHost = extractHost(hostAndPort);
$: hostName = formatSeedHost(seedHost.host);
const getProjectsAndStats = async (
seed: Seed,
@@ -28,7 +30,7 @@
projects: ProjectInfo[];
}> => {
const stats = await seed.getStats();
const projects = await Project.getProjects(seedHost, { perPage: 10 });
const projects = await Project.getProjects(seed.addr, { perPage: 10 });
return { stats, projects };
};
</script>
@@ -81,7 +83,7 @@
<title>{hostName}</title>
</svelte:head>
{#await Seed.lookup(host, Number(port))}
{#await Seed.lookup(seedHost)}
<main class="layout-centered">
<Loading center />
</main>
@@ -99,17 +101,19 @@
<div class="fields">
<!-- Seed Address -->
<div class="txt-highlight">Address</div>
<SeedAddress {seed} port={seed.node.port} />
<SeedAddress
{seed}
port={seed.node.port ?? config.seeds.defaultHttpdPort} />
<!-- Seed ID -->
<div class="txt-highlight">Seed ID</div>
<div class="seed-label">
{formatSeedId(seed.id)}
{formatNodeId(seed.id)}
<Clipboard small text={seed.id} />
</div>
<div class="layout-desktop" />
<!-- API Port -->
<div class="txt-highlight">API Port</div>
<div>{port}</div>
<div>{seed.addr.port}</div>
<div class="layout-desktop" />
<!-- API Version -->
<div class="txt-highlight">Version</div>
@@ -133,7 +137,7 @@
{:catch}
<div class="layout-centered">
<NotFound
title={host}
title={seedHost.host}
subtitle="Not able to query information from this seed." />
</div>
{/await}
+5 -3
View File
@@ -6,6 +6,7 @@
import * as router from "@app/lib/router";
import List from "@app/components/List.svelte";
import Widget from "@app/views/projects/Widget.svelte";
import { config } from "@app/lib/config";
export let seed: Seed;
export let projects: proj.ProjectInfo[];
@@ -39,9 +40,10 @@
params: {
view: { resource: "tree" },
id: project.id,
seed: seed.addr.port
? `${seed.addr.host}:${seed.addr.port}`
: seed.addr.host,
seed:
seed.addr.port === config.seeds.defaultHttpdPort
? seed.addr.host
: `${seed.addr.host}:${seed.addr.port}`,
revision: project.head ?? undefined,
hash: undefined,
search: undefined,
+17 -8
View File
@@ -3,7 +3,6 @@ import {
expect,
projectFixtureUrl,
rid,
ridPrefix,
seedRemote,
test,
} from "@tests/support/fixtures.js";
@@ -37,33 +36,43 @@ test("copy to clipboard", async ({ page, browserName, context }) => {
const clipboardContent = await page.evaluate<string>(
"navigator.clipboard.readText()",
);
expect(clipboardContent).toBe(`${ridPrefix}${rid}`);
expect(clipboardContent).toBe(`${rid}`);
}
// `rad clone` URL.
{
await page.getByText("Clone").click();
await page
.locator(`text=rad clone rad://0.0.0.0/${rid.substring(0, 6)}`)
.hover();
await page.locator(`text=rad clone ${rid.substring(0, 6)}`).hover();
await page
.locator(".clone-url-wrapper > span")
.first()
.locator(".clipboard")
.click();
await expectClipboard(`rad clone rad://0.0.0.0/${rid}`, page);
await expectClipboard(`rad clone ${rid}`, page);
}
// `git clone` URL.
{
await page.getByText("Clone").click();
await page.locator(`text=https://0.0.0.0/${rid.substring(0, 6)}`).hover();
await page
.locator(
`text=git clone https://0.0.0.0/${rid
.replace("rad:", "")
.substring(0, 10)}`,
)
.hover();
await page
.locator(".clone-url-wrapper > span")
.last()
.locator(".clipboard")
.click();
await expectClipboard(`https://0.0.0.0/${rid}.git`, page);
await expectClipboard(
`git clone https://0.0.0.0/${rid.replace(
"rad:",
"",
)}.git source-browsing`,
page,
);
}
await page.goto("/seeds/radicle.local");
+25 -53
View File
@@ -6,7 +6,6 @@ import {
expect,
projectFixtureUrl,
rid,
ridPrefix,
test,
} from "@tests/support/fixtures.js";
import { expectUrlPersistsReload } from "@tests/support/router";
@@ -29,7 +28,7 @@ test("navigate to project", async ({ page }) => {
// Header.
{
const name = page.locator("text=source-browsing");
const id = page.locator(`text=${ridPrefix}${rid}`);
const id = page.locator(`text=${rid}`);
const description = page.locator(
"text=Git repository for source browsing tests",
);
@@ -245,10 +244,10 @@ test("clone modal", async ({ page }) => {
await page.goto(projectFixtureUrl);
await page.getByText("Clone").click();
await expect(page.locator(`text=rad clone ${rid}`)).toBeVisible();
await expect(
page.locator(`text=rad clone rad://0.0.0.0/${rid}`),
page.locator(`text=https://0.0.0.0/${rid.replace("rad:", "")}.git`),
).toBeVisible();
await expect(page.locator(`text=https://0.0.0.0/${rid}.git`)).toBeVisible();
});
test("peer and branch switching", async ({ page }) => {
@@ -257,18 +256,13 @@ test("peer and branch switching", async ({ page }) => {
// Alice's peer.
{
await page.getByTitle("Change peer").click();
if (process.env.HEARTWOOD) {
await page.locator(`text=${aliceRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`did:key:${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
);
} else {
await page.locator("text=alice").click();
await expect(page.getByTitle("Change peer")).toHaveText("alice delegate");
}
await page.locator(`text=${aliceRemote.substring(0, 6)}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`did:key:${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
);
await expect(
page.locator(
`text=source-browsing / ${aliceRemote.substring(
`text=source-browsing / did:key:${aliceRemote.substring(
0,
6,
)}…${aliceRemote.slice(-6)}`,
@@ -326,32 +320,19 @@ test("peer and branch switching", async ({ page }) => {
// Bob's peer.
{
await page.getByTitle("Change peer").click();
if (process.env.HEARTWOOD) {
await page.locator(`text=${bobRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
);
} else {
await page.locator("text=bob").click();
await expect(page.getByTitle("Change peer")).toHaveText("bob");
}
await page.locator(`text=${bobRemote.substring(0, 6)}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
);
await expect(page.getByTitle("Change peer")).not.toHaveText("delegate");
// Default `main` branch.
{
if (process.env.HEARTWOOD) {
await expect(page.getByTitle("Current branch")).toContainText(
"main 1e0bb83",
);
await expectCounts({ commits: 9, contributors: 2 }, page);
await expect(page.locator("text=1e0bb83 Update readme")).toBeVisible();
} else {
await expect(page.getByTitle("Current branch")).toContainText(
"main 2b32f6f",
);
await expectCounts({ commits: 9, contributors: 2 }, page);
await expect(page.locator("text=2b32f6f Update readme")).toBeVisible();
}
await expect(page.getByTitle("Current branch")).toContainText(
"main 1e0bb83",
);
await expectCounts({ commits: 9, contributors: 2 }, page);
await expect(page.locator("text=1e0bb83 Update readme")).toBeVisible();
}
}
});
@@ -360,11 +341,7 @@ test("only one modal can be open at a time", async ({ page }) => {
await page.goto(projectFixtureUrl);
await page.getByTitle("Change peer").click();
if (process.env.HEARTWOOD) {
await page.locator(`text=${aliceRemote.substring(0, 6)}`).click();
} else {
await page.locator(`text=alice ${aliceRemote.substring(0, 6)}`).click();
}
await page.locator(`text=${aliceRemote.substring(0, 6)}`).click();
await page.getByText("Clone").click();
await expect(page.locator("text=Code font")).not.toBeVisible();
@@ -381,11 +358,7 @@ test("only one modal can be open at a time", async ({ page }) => {
await page.getByTitle("Change peer").click();
await expect(page.locator("text=Code font")).not.toBeVisible();
await expect(page.locator("text=Use the Radicle CLI")).not.toBeVisible();
if (process.env.HEARTWOOD) {
await expect(page.locator(`text=${bobRemote}`)).toBeVisible();
} else {
await expect(page.locator("text=bob hyyzz9")).toBeVisible();
}
await expect(page.locator(`text=${bobRemote.substring(0, 6)}`)).toBeVisible();
await expect(page.locator("text=feature/branch")).not.toBeVisible();
await page.locator('button[name="Settings"]').click();
@@ -398,7 +371,7 @@ test("only one modal can be open at a time", async ({ page }) => {
test.describe("browser error handling", () => {
test("error appears when folder can't be loaded", async ({ page }) => {
await page.route(
`**/v1/projects/${ridPrefix}${rid}/tree/${aliceMainHead}/markdown/`,
`**/v1/projects/${rid}/tree/${aliceMainHead}/markdown/`,
route => route.fulfill({ status: 500 }),
);
@@ -413,7 +386,7 @@ test.describe("browser error handling", () => {
});
test("error appears when file can't be loaded", async ({ page }) => {
await page.route(
`**/v1/projects/${ridPrefix}${rid}/blob/${aliceMainHead}/.hidden`,
`**/v1/projects/${rid}/blob/${aliceMainHead}/.hidden`,
route => route.fulfill({ status: 500 }),
);
@@ -423,9 +396,8 @@ test.describe("browser error handling", () => {
await expect(page.locator("text=Not able to load file")).toBeVisible();
});
test("error appears when README can't be loaded", async ({ page }) => {
await page.route(
`**/v1/projects/${ridPrefix}${rid}/readme/${aliceMainHead}`,
route => route.fulfill({ status: 500 }),
await page.route(`**/v1/projects/${rid}/readme/${aliceMainHead}`, route =>
route.fulfill({ status: 500 }),
);
await page.goto(projectFixtureUrl);
@@ -435,7 +407,7 @@ test.describe("browser error handling", () => {
});
test("error appears when navigating to missing file", async ({ page }) => {
await page.route(
`**/v1/projects/${ridPrefix}${rid}/blob/${aliceMainHead}/.hidden`,
`**/v1/projects/${rid}/blob/${aliceMainHead}/.hidden`,
route => route.fulfill({ status: 500 }),
);
@@ -447,7 +419,7 @@ test.describe("browser error handling", () => {
page,
}) => {
await page.route(
`**/v1/projects/${ridPrefix}${rid}/blob/${aliceMainHead}/src/black-square.png`,
`**/v1/projects/${rid}/blob/${aliceMainHead}/src/black-square.png`,
route => route.fulfill({ status: 404 }),
);
+6 -25
View File
@@ -5,18 +5,12 @@ import {
bobRemote,
} from "@tests/support/fixtures.js";
const modifiedFileFixture = `${projectFixtureUrl}/remotes/${bobRemote}/commits/${
process.env.HEARTWOOD
? "1e0bb83a89b63da815f2fc24e7ae3c5ceb30e0eb"
: "2b32f6fe50090ebdb4cd7441e943330da3e6ff04"
}`;
const modifiedFileFixture = `${projectFixtureUrl}/remotes/${bobRemote}/commits/${"1e0bb83a89b63da815f2fc24e7ae3c5ceb30e0eb"}`;
test("navigation from commit list", async ({ page }) => {
await page.goto(projectFixtureUrl);
await page.getByTitle("Change peer").click();
await page
.locator(process.env.HEARTWOOD ? `text=${bobRemote}` : "text=bob hyyzz9")
.click();
await page.locator(`text=${bobRemote.substring(0, 6)}`).click();
await page.locator('role=button[name="Commit count"]').click();
await page.locator("text=Update readme").click();
@@ -35,13 +29,7 @@ test("relative timestamps", async ({ page }) => {
});
await page.goto(modifiedFileFixture);
await expect(
page.locator(
`.commit header >> text=${
process.env.HEARTWOOD
? "Bob Belcher committed now"
: "bob committed 22 hours ago"
}`,
),
page.locator(`.commit header >> text=${"Bob Belcher committed now"}`),
).toBeVisible();
});
@@ -52,16 +40,9 @@ test("modified file", async ({ page }) => {
{
const header = page.locator(".commit header");
await expect(header.locator("text=Update readme")).toBeVisible();
if (!process.env.HEARTWOOD) {
await expect(header.locator("text=Verified")).toBeVisible();
await expect(
header.locator("text=2b32f6fe50090ebdb4cd7441e943330da3e6ff04"),
).toBeVisible();
} else {
await expect(
header.locator("text=1e0bb83a89b63da815f2fc24e7ae3c5ceb30e0eb"),
).toBeVisible();
}
await expect(
header.locator("text=1e0bb83a89b63da815f2fc24e7ae3c5ceb30e0eb"),
).toBeVisible();
}
// Diff header.
+18 -66
View File
@@ -13,15 +13,10 @@ test("peer and branch switching", async ({ page }) => {
// Alice's peer.
{
await page.getByTitle("Change peer").click();
if (process.env.HEARTWOOD) {
await page.locator(`text=${aliceRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`did:key:${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
);
} else {
await page.locator("text=alice hybg18").click();
await expect(page.getByTitle("Change peer")).toHaveText("alice delegate");
}
await page.locator(`text=${aliceRemote.substring(0, 6)}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`did:key:${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
);
await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
await expect(
@@ -66,20 +61,15 @@ test("peer and branch switching", async ({ page }) => {
// Bob's peer.
{
await page.getByTitle("Change peer").click();
if (process.env.HEARTWOOD) {
await page.locator(`text=${bobRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
);
} else {
await page.locator("text=bob hyyzz9").click();
await expect(page.getByTitle("Change peer")).toHaveText("bob");
}
await page.locator(`text=${bobRemote.substring(0, 6)}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
);
await expect(page.getByText("Tuesday, December 20, 2022")).toBeVisible();
await expect(
page.locator(".commit-group-headers").first().locator(".commit-teaser"),
).toHaveCount(process.env.HEARTWOOD ? 1 : 3);
).toHaveCount(1);
await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
await expect(
@@ -88,13 +78,8 @@ test("peer and branch switching", async ({ page }) => {
await page.pause();
const latestCommit = page.locator(".commit-teaser").first();
if (process.env.HEARTWOOD) {
await expect(latestCommit).toContainText("Update readme");
await expect(latestCommit).toContainText("1e0bb83");
} else {
await expect(latestCommit).toContainText("Update readme");
await expect(latestCommit).toContainText("2b32f6f");
}
await expect(latestCommit).toContainText("Update readme");
await expect(latestCommit).toContainText("1e0bb83");
const earliestCommit = page.locator(".commit-teaser").last();
await expect(earliestCommit).toContainText(
@@ -104,30 +89,6 @@ test("peer and branch switching", async ({ page }) => {
}
});
test("verified badge", async ({ page }) => {
await page.goto(projectFixtureUrl);
await page.locator('role=button[name="Commit count"]').click();
await page.getByTitle("Change peer").click();
if (process.env.HEARTWOOD) {
await page.locator(`text=${bobRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
);
} else {
await page.locator("text=bob hyyzz9").click();
await expect(page.getByTitle("Change peer")).toHaveText("bob");
// not applicable to heartwood?
await page.locator("text=Verified").hover();
await expect(
page.locator(
"text=This commit was signed with the committer's radicle key.",
),
).toBeVisible();
await expect(page.locator(`text=bob committed ${bobRemote}`)).toBeVisible();
}
});
test("relative timestamps", async ({ page }) => {
await page.addInitScript(() => {
window.initializeTestStubs = () => {
@@ -143,22 +104,13 @@ test("relative timestamps", async ({ page }) => {
await page.locator('role=button[name="Commit count"]').click();
await page.getByTitle("Change peer").click();
if (process.env.HEARTWOOD) {
await page.locator(`text=${bobRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
);
const latestCommit = page.locator(".commit-teaser").first();
await expect(latestCommit).toContainText("Bob Belcher committed now");
await expect(latestCommit).toContainText("1e0bb83");
} else {
await page.locator("text=bob hyyzz9").click();
await expect(page.getByTitle("Change peer")).toHaveText("bob");
const latestCommit = page.locator(".commit-teaser").first();
await expect(latestCommit).toContainText("bob committed 22 hours ago");
await expect(latestCommit).toContainText("2b32f6f");
}
await page.locator(`text=${bobRemote.substring(0, 6)}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
);
const latestCommit = page.locator(".commit-teaser").first();
await expect(latestCommit).toContainText("Bob Belcher committed now");
await expect(latestCommit).toContainText("1e0bb83");
const earliestCommit = page.locator(".commit-teaser").last();
await expect(earliestCommit).toContainText(
"Alice Liddell committed last month",
+3 -4
View File
@@ -2,7 +2,6 @@ import {
test,
expect,
rid,
ridPrefix,
projectFixtureUrl,
} from "@tests/support/fixtures.js";
@@ -10,11 +9,11 @@ test("navigate to existing project", async ({ page }) => {
await page.goto("/");
const searchInput = page.getByPlaceholder("Search a name…");
await searchInput.click();
await searchInput.fill(`${ridPrefix}${rid}`);
await searchInput.fill(`${rid}`);
await searchInput.press("Enter");
await expect(page).toHaveURL(`${projectFixtureUrl}/tree`);
await expect(searchInput).not.toHaveValue(`${ridPrefix}${rid}`);
await expect(searchInput).not.toHaveValue(`${rid}`);
});
test("navigate to a project that does not exist", async ({ page }) => {
@@ -22,7 +21,7 @@ test("navigate to a project that does not exist", async ({ page }) => {
const searchInput = page.getByPlaceholder("Search a name…");
await searchInput.click();
const nonExistantId = `${ridPrefix}:zt${rid.substring(2)}`;
const nonExistantId = "rad:zAAAAAAAAAAAAAAAAAAAAAAAAAAA";
await searchInput.fill(nonExistantId);
await searchInput.press("Enter");
+2 -3
View File
@@ -2,7 +2,6 @@ import {
aliceMainHead,
expect,
rid,
ridPrefix,
seedPort,
seedRemote,
seedVersion,
@@ -44,8 +43,8 @@ test("seed projects", async ({ page }) => {
// Show project ID on hover.
{
await expect(project.locator(`text=${ridPrefix}${rid}`)).not.toBeVisible();
await expect(project.locator(`text=${rid}`)).not.toBeVisible();
await project.hover();
await expect(project.locator(`text=${ridPrefix}${rid}`)).toBeVisible();
await expect(project.locator(`text=${rid}`)).toBeVisible();
}
});
Binary file not shown.
Binary file not shown.
+17 -21
View File
@@ -49,6 +49,9 @@ export const test = base.extend<{
window.APP_CONFIG = {
reactions: [],
seeds: {
defaultHttpdPort: 8080,
defaultHttpdScheme: "http",
defaultNodePort: 8776,
pinned: [{ host: "0.0.0.0", emoji: "🚀" }],
},
projects: { pinned: [] },
@@ -124,14 +127,13 @@ function log(text: string, label: string, outputLog: Stream.Writable) {
}
}
export const appConfigWithFixture = process.env.HEARTWOOD
? configFixtureHeartwood
: configFixture;
export function configFixture() {
window.APP_CONFIG = {
reactions: [],
seeds: {
defaultHttpdPort: 8080,
defaultHttpdScheme: "http",
defaultNodePort: 8776,
pinned: [{ host: "0.0.0.0", emoji: "🚀" }],
},
projects: {
@@ -146,10 +148,13 @@ export function configFixture() {
};
}
export function configFixtureHeartwood() {
export function appConfigWithFixture() {
window.APP_CONFIG = {
reactions: [],
seeds: {
defaultHttpdPort: 8080,
defaultHttpdScheme: "http",
defaultNodePort: 8776,
pinned: [{ host: "0.0.0.0", emoji: "🚀" }],
},
projects: {
@@ -165,19 +170,10 @@ export function configFixtureHeartwood() {
}
export const aliceMainHead = "fcc929424b82984b7cbff9c01d2e20d9b1249842";
export const aliceRemote = process.env.HEARTWOOD
? "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
: "hybg18bc4cu8z9xtj44skxperfdpxpp1wp8zygyzti5kfiggdizfxy";
export const bobRemote = process.env.HEARTWOOD
? "z6MksMTThc1aDU2Ztc43jJUivuyBLNWiLsDf4X65rABe7HbA"
: "hyyzz9w4ffg16zftjki3enajm4mkqkayb5ch1p6ns3f83np1hqkrp6";
export const rid = process.env.HEARTWOOD
? "zKtT7DmF9H34KkvcKj9PHW19WzjT"
: "hnrkdi8be7n4hhqoz9rpzrgd68u9dr3zsxgmy";
export const ridPrefix = process.env.HEARTWOOD ? "rad:" : "rad:git:";
export const projectFixtureUrl = `/seeds/0.0.0.0/${ridPrefix}${rid}`;
export const seedPort = process.env.HEARTWOOD ? 8080 : "8777";
export const seedVersion = process.env.HEARTWOOD ? "0.1.0" : "0.2.0";
export const seedRemote = process.env.HEARTWOOD
? "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"
: "hybuytx44z9cfsm5739wecia9j4b7expgc15qkazph59szp57m4d3o";
export const aliceRemote = "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
export const bobRemote = "z6MksMTThc1aDU2Ztc43jJUivuyBLNWiLsDf4X65rABe7HbA";
export const rid = "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT";
export const projectFixtureUrl = `/seeds/0.0.0.0/${rid}`;
export const seedPort = 8080;
export const seedVersion = "0.1.0";
export const seedRemote = "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
+6 -14
View File
@@ -11,28 +11,20 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
async function assertHttpApiRunning(): Promise<void> {
const notRunningMessage =
"The http-api server with test fixtures needs to be running.\n" +
(process.env.HEARTWOOD
? "👉 You can start it with `./scripts/run-httpd-with-fixtures`\n"
: "👉 You can start it with `./scripts/run-http-api-with-fixtures`\n");
"👉 You can start it with `./scripts/run-httpd-with-fixtures`\n";
let peerId: string | undefined = undefined;
let nodeId: string | undefined = undefined;
try {
if (process.env.HEARTWOOD) {
const response = await fetch(`http://0.0.0.0:${seedPort}/api`);
const data = await response.json();
peerId = data.node.id;
} else {
const response = await fetch(`http://0.0.0.0:${seedPort}`);
const data = await response.json();
peerId = data.peer.id;
}
const response = await fetch(`http://0.0.0.0:${seedPort}/api`);
const data = await response.json();
nodeId = data.node.id;
} catch (err) {
console.error(err);
throw new Error(notRunningMessage);
}
if (peerId !== seedRemote) {
if (nodeId !== seedRemote) {
const wrongSeedMessage = `The server on port ${seedPort} doesn't have the right fixtures.\n`;
throw new Error(wrongSeedMessage + notRunningMessage);
}
+51 -26
View File
@@ -9,6 +9,15 @@ describe("Format functions", () => {
expect(utils.formatLocationHash(hash)).toEqual(expected);
});
test.each([
{
id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
expected: "rad:zKtT7D…19WzjT",
},
])("formatRepositoryId $id => $expected", ({ id, expected }) => {
expect(utils.formatRepositoryId(id)).toEqual(expected);
});
test.each([
{
id: "did:key:z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
@@ -22,22 +31,17 @@ describe("Format functions", () => {
expect(utils.formatNodeId(id)).toEqual(expected);
});
test("formatRadicleId", () => {
if (process.env.HEARTWOOD) {
expect(utils.formatRadicleId("rad:zKtT7DmF9H34KkvcKj9PHW19WzjT")).toEqual(
"rad:zKtT7D…19WzjT",
);
} else {
expect(
utils.formatRadicleId("rad:git:hnrkemobagsicpf9sr95o3g551otspcd84c9o"),
).toEqual("rad:git:hnrkem…d84c9o");
}
});
test("formatRadicleId throw when wrong ID", () => {
expect(() =>
utils.formatRadicleId("hnrkemobagsicpf9sr95o3g551otspcd84c9o"),
).toThrow();
test.each([
{
id: "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5",
expected: "rad:z4V1sj…p4DtH5",
},
{
id: "z4V1sjrXqjvFdnCUbxPFqd5p4DtH5",
expected: "rad:z4V1sj…p4DtH5",
},
])("formatRepositoryId $id => $expected", ({ id, expected }) => {
expect(utils.formatRepositoryId(id)).toEqual(expected);
});
test.each([
@@ -67,19 +71,40 @@ describe("String Assertions", () => {
});
test.each([
process.env.HEARTWOOD
? { id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", expected: true }
: { id: "rad:git:hnrkemobagsicpf9sr95o3g551otspcd84c9o", expected: true },
{ id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", expected: true },
{ id: "z2H9aHDurxd8Uvx2jsvW4e5mamy5S", expected: true },
{ id: "rad:BBBBBBBBBBBBBBBBBBBBBBBBBBBB", expected: false },
{ id: "0x1234567890123456789012345678901234567890", expected: false },
])("isRadicleId $id => $expected", ({ id, expected }) => {
expect(utils.isRadicleId(id)).toEqual(expected);
{
id: "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
expected: false,
},
{
id: "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
expected: false,
},
])("isRepositoryId $id => $expected", ({ id, expected }) => {
expect(utils.isRepositoryId(id)).toEqual(expected);
});
test.each([
{ id: "hnrkj4c35uoyceb3d1dsscx8qq55cikrd1aio", expected: true },
{
id: "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
expected: true,
},
{
id: "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
expected: true,
},
{
id: "did:key:CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
expected: false,
},
{ id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", expected: false },
{ id: "z2H9aHDurxd8Uvx2jsvW4e5mamy5S", expected: false },
{ id: "0x1234567890123456789012345678901234567890", expected: false },
])("isPeerId $id => $expected", ({ id, expected }) => {
expect(utils.isPeerId(id)).toEqual(expected);
])("isNodeId $id => $expected", ({ id, expected }) => {
expect(utils.isNodeId(id)).toEqual(expected);
});
test.each([
@@ -131,8 +156,8 @@ describe("Parse Functions", () => {
pubkey: "z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
},
},
])("parseNid", ({ input, expected }) => {
expect(utils.parseNid(input)).toEqual(expected);
])("parseNodeId", ({ input, expected }) => {
expect(utils.parseNodeId(input)).toEqual(expected);
});
});
-1
View File
@@ -12,7 +12,6 @@ function defineConstants() {
const constants = {
VITEST: process.env.VITEST !== undefined,
PLAYWRIGHT: process.env.PLAYWRIGHT_TEST_BASE_URL !== undefined,
HEARTWOOD: process.env.HEARTWOOD !== undefined,
};
// Don't overwrite HASH_ROUTING in Playwright tests, so we can control it