Skip to content

Commit

Permalink
Allow track and untrack of a project through the TrackButton
Browse files Browse the repository at this point in the history
- Add support for checking the tracking table from `radicle-httpd` to
  get a more precise information if the project is being tracked by the
  local node.
- When starting to track a project from a remote node, we also fetch
  that project.
  - If we are on the local node we only change the tracking status.
  - The default scope for tracking a project from the web is `all`.
  • Loading branch information
sebastinez committed Dec 6, 2023
1 parent fb12980 commit e8b98f8
Show file tree
Hide file tree
Showing 4 changed files with 180 additions and 83 deletions.
82 changes: 82 additions & 0 deletions httpd-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
Tree,
DiffResponse,
} from "./lib/project.js";
import type { SuccessResponse } from "./lib/shared.js";
import type { Comment, Embed } from "./lib/project/comment.js";
import type {
Commit,
Expand Down Expand Up @@ -38,6 +39,7 @@ import { z, array, boolean, literal, number, object, string, union } from "zod";
import * as project from "./lib/project.js";
import * as session from "./lib/session.js";
import { Fetcher } from "./lib/fetcher.js";
import { successResponseSchema } from "./lib/shared.js";

export type {
BaseUrl,
Expand Down Expand Up @@ -127,6 +129,36 @@ const nodeInfoSchema = object({
),
});

export type NodeTracking = z.infer<typeof nodeTrackingSchema>;

const nodeTrackingSchema = array(
object({
id: string(),
scope: string(),
policy: string(),
}),
);

export type FetchResults = z.infer<typeof fetchResultsSchema>;

const fetchResultsSchema = union([
object({
status: literal("success"),
updated: array(
union([
object({
updated: object({ name: string(), oid: string(), new: string() }),
}),
object({ created: object({ name: string(), oid: string() }) }),
object({ deleted: object({ name: string(), oid: string() }) }),
object({ skipped: object({ name: string(), oid: string() }) }),
]),
),
namespaces: array(string()),
}),
object({ status: literal("failed"), reason: string() }),
]);

export interface NodeStats {
projects: { count: number };
users: { count: number };
Expand Down Expand Up @@ -189,6 +221,56 @@ export class HttpdClient {
);
}

public async getTracking(options?: RequestOptions): Promise<NodeTracking> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: "node/policies/repos",
options,
},
nodeTrackingSchema,
);
}

public async seedById(
id: string,
query: { from?: string },
authToken: string,
options?: RequestOptions,
): Promise<SuccessResponse | (SuccessResponse & { results: FetchResults })> {
return this.#fetcher.fetchOk(
{
method: "PUT",
path: `node/policies/repos/${id}`,
query,
headers: { Authorization: `Bearer ${authToken}` },
body: { type: "track", scope: "all" },
options,
},
union([
successResponseSchema,
successResponseSchema.merge(object({ results: fetchResultsSchema })),
]),
);
}

public async stopSeedingById(
id: string,
authToken: string,
options?: RequestOptions,
): Promise<SuccessResponse> {
return this.#fetcher.fetchOk(
{
method: "DELETE",
path: `node/policies/repos/${id}`,
headers: { Authorization: `Bearer ${authToken}` },
body: { type: "untrack" },
options,
},
successResponseSchema,
);
}

public async getNode(options?: RequestOptions): Promise<Node> {
return this.#fetcher.fetchOk(
{
Expand Down
78 changes: 14 additions & 64 deletions src/views/projects/Header/SeedButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,74 +2,24 @@
import { pluralize } from "@app/lib/pluralize";
import Button from "@app/components/Button.svelte";
import Command from "@app/components/Command.svelte";
import ExternalLink from "@app/components/ExternalLink.svelte";
import IconSmall from "@app/components/IconSmall.svelte";
import Popover from "@app/components/Popover.svelte";
export let projectId: string;
export let seedings: number;
export let seeding: boolean;
$: buttonTitle = seeding ? "Seeding" : "Seed";
export let disabled: boolean = false;
</script>

<style>
.seed-label {
display: block;
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
margin-bottom: 0.75rem;
}
code {
font-family: var(--font-family-monospace);
font-size: var(--font-size-small);
background-color: var(--color-fill-ghost);
border-radius: var(--border-radius-tiny);
padding: 0.125rem 0.25rem;
}
</style>

<Popover popoverPositionTop="3rem" popoverPositionRight="0">
<Button
slot="toggle"
let:toggle
on:click={toggle}
size="large"
variant={seeding ? "secondary-toggle-on" : "secondary-toggle-off"}
title="Tracked by {seedings} {pluralize('node', seedings)}">
<IconSmall name="network" />
<span>
{buttonTitle}
<span style:font-weight="var(--font-weight-regular)">
{seedings}
</span>
<Button
{disabled}
on:click
size="large"
variant={seeding ? "secondary-toggle-on" : "secondary-toggle-off"}
title="Seeded by {seeding} {pluralize('node', seedings)}">
<IconSmall name="network" />
<span>
{seeding ? "Seeding" : "Seed"}
<span style:font-weight="var(--font-weight-regular)">
{seedings}
</span>
</Button>

<div slot="popover" style:width={seeding ? "19.5rem" : "30.5rem"}>
<div class="seed-label">
Use the <ExternalLink href="https://radicle.xyz/#try">
Radicle CLI
</ExternalLink>
to {seeding ? "stop seeding" : "seed"} this project.
{#if !seeding}
<br />
<br />
The
<code>seed</code>
command serves a dual purpose:
<ul style:padding="0 1rem" style:margin-top="0.5rem">
<li>
Keeps your local Radicle node in sync with updates from this
project.
</li>
<li>
Propagates them across the Radicle network to other peers like you.
</li>
</ul>
{/if}
</div>
<Command command={`rad seed ${projectId} ${seeding ? "--delete" : ""}`} />
</div>
</Popover>
</span>
</Button>
72 changes: 70 additions & 2 deletions src/views/projects/Layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@
import dompurify from "dompurify";
import * as modal from "@app/lib/modal";
import capitalize from "lodash/capitalize";
import markdown from "@app/lib/markdown";
import { twemoji } from "@app/lib/utils";
import { HttpdClient } from "@httpd-client";
import { httpdStore, api } from "@app/lib/httpd";
import { isLocal, twemoji } from "@app/lib/utils";
import Badge from "@app/components/Badge.svelte";
import CloneButton from "@app/views/projects/Header/CloneButton.svelte";
import CopyableId from "@app/components/CopyableId.svelte";
import ErrorModal from "@app/modals/ErrorModal.svelte";
import Header from "@app/views/projects/Header.svelte";
import Link from "@app/components/Link.svelte";
import SeedButton from "@app/views/projects/Header/SeedButton.svelte";
Expand All @@ -20,6 +24,66 @@
export let project: Project;
export let seeding: boolean;
let editSeedingInProgress = false;
async function editSeeding() {
if ($httpdStore.state === "authenticated") {
try {
editSeedingInProgress = true;
if (seeding) {
await api.stopSeedingById(project.id, $httpdStore.session.id);
} else {
// If we are on a remote node, we pass the node id to be fetched from
let from: undefined | string = undefined;
if (!isLocal(baseUrl.hostname)) {
const remoteApi = new HttpdClient(baseUrl);
const node = await remoteApi.getNode();
from = node.id;
}
const result = await api.seedById(
project.id,
{ from },
$httpdStore.session.id,
);
seeding = !seeding;
if ("results" in result && result.results.status === "failed") {
modal.show({
component: ErrorModal,
props: {
title: "Fetching project failed",
subtitle: [
"We were able to seed this project, but fetching it failed.",
],
error: {
message: result.results.reason,
},
},
});
}
}
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Project seeding failed",
subtitle: [
"There was an error while trying to seed this project.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
editSeedingInProgress = false;
}
}
}
const render = (content: string): string =>
dompurify.sanitize(markdown.parse(content) as string);
</script>
Expand Down Expand Up @@ -99,7 +163,11 @@
<div
class="layout-desktop-flex"
style="margin-left: auto; display: flex; gap: 0.5rem;">
<SeedButton {seeding} seedings={project.seeding} projectId={project.id} />
<SeedButton
disabled={editSeedingInProgress}
{seeding}
seedings={project.seeding}
on:click={editSeeding} />
<CloneButton {baseUrl} id={project.id} name={project.name} />
</div>
</div>
Expand Down
31 changes: 14 additions & 17 deletions src/views/projects/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ import type {
Tree,
} from "@httpd-client";

import { HttpdClient } from "@httpd-client";
import * as Syntax from "@app/lib/syntax";
import { isLocal, unreachable } from "@app/lib/utils";
import { nodePath } from "@app/views/nodes/router";
import * as httpd from "@app/lib/httpd";
import { HttpdClient } from "@httpd-client";
import { ResponseError } from "@httpd-client/lib/fetcher";
import { nodePath } from "@app/views/nodes/router";
import { unreachable } from "@app/lib/utils";

export const COMMITS_PER_PAGE = 30;
export const PATCHES_PER_PAGE = 10;
Expand Down Expand Up @@ -245,21 +246,17 @@ function parseRevisionToOid(
}

async function isLocalNodeSeeding(route: ProjectRoute): Promise<boolean> {
if (isLocal(route.node.hostname)) {
return true;
} else {
try {
await httpd.api.project.getById(route.project);
return true;
} catch (error: any) {
if (error.status === 404) {
return false;
} else {
// Either `radicle-httpd` isn't running or there was some other
// error.
return false;
}
try {
const tracking = await httpd.api.getTracking();
return tracking.some(({ id }) => id === route.project);
} catch (error) {
if (error instanceof ResponseError && error.status === 404) {
return false;
}

// Either `radicle-httpd` isn't running or there was some other
// error.
return false;
}
}

Expand Down

0 comments on commit e8b98f8

Please sign in to comment.