Use configured explorer and seed for links

explorerUrl() hardcoded both the seed ("iris.radicle.network") and
the explorer base ("https://radicle.network"), so every "Copy Link"
and "Open in …" action ignored the user's config and could point at
a host that does not replicate the repo.

Expose publicExplorer and preferredSeeds on the Config type (both
already carried by radicle::profile::Config) and thread config
through to every explorerUrl() call site. URLs are now built from
the publicExplorer template, filling $host from the first preferred
seed (falling back to rosa only when none are configured), and link
labels show the configured explorer's host.

Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
hlatimer266
2026-07-21 13:26:31 +02:00
committed by Rūdolfs Ošiņš
co-authored by Rūdolfs Ošiņš
parent 7fe26dc4e5
commit 74aaa6ef14
13 changed files with 109 additions and 28 deletions
@@ -18,4 +18,13 @@ export type Config = {
seedingPolicy: { default: "allow"; scope: "followed" | "all" } | {
default: "block";
};
/**
* Public explorer URL template, e.g. `https://radicle.network/nodes/$host/$rid$path`.
*/
publicExplorer: string;
/**
* Preferred seed addresses, in priority order. Used to pick a host for
* explorer links.
*/
preferredSeeds: Array<string>;
};
+9 -1
View File
@@ -1,9 +1,10 @@
use radicle::crypto::PublicKey;
use radicle::explorer::Explorer;
use serde::Serialize;
use ts_rs::TS;
use radicle::node::Alias;
use radicle::node::config::DefaultSeedingPolicy;
use radicle::node::config::{ConnectAddress, DefaultSeedingPolicy};
/// Service configuration.
#[derive(Debug, TS, Serialize, PartialEq)]
@@ -21,6 +22,13 @@ pub struct Config {
#[serde(default)]
#[ts(type = "{ default: 'allow', scope: 'followed' | 'all' } | { default: 'block' }")]
pub seeding_policy: DefaultSeedingPolicy,
/// Public explorer URL template, e.g. `https://radicle.network/nodes/$host/$rid$path`.
#[ts(as = "String")]
pub public_explorer: Explorer,
/// Preferred seed addresses, in priority order. Used to pick a host for
/// explorer links.
#[ts(as = "Vec<String>")]
pub preferred_seeds: Vec<ConnectAddress>,
}
pub struct Version {
+4
View File
@@ -19,6 +19,8 @@ pub trait Profile {
public_key: p.public_key,
alias: p.config.node.alias.clone(),
seeding_policy: p.config.node.seeding_policy,
public_explorer: p.config.public_explorer.clone(),
preferred_seeds: p.config.preferred_seeds.clone(),
}
}
@@ -55,6 +57,8 @@ mod test {
public_key: *signer.public_key(),
alias: Alias::from_str("seed").unwrap(),
seeding_policy: config::DefaultSeedingPolicy::Block,
public_explorer: state.profile.config.public_explorer.clone(),
preferred_seeds: state.profile.config.preferred_seeds.clone(),
}
)
}
+1
View File
@@ -199,6 +199,7 @@
<SidebarRepoList
initialRepos={sidebarData.repos}
initialSeededNotReplicated={sidebarData.seededNotReplicated}
config={sidebarData.config}
{activeRepo} />
</div>
+11 -3
View File
@@ -4,7 +4,12 @@
import debounce from "lodash/debounce";
import { writeToClipboard } from "@app/lib/invoke";
import { didFromPublicKey, explorerUrl, truncateDid } from "@app/lib/utils";
import {
didFromPublicKey,
explorerHost,
explorerUrl,
truncateDid,
} from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import DropdownListItem from "@app/components/DropdownListItem.svelte";
@@ -60,11 +65,14 @@
style:text-decoration="none"
style:width="100%"
onclick={closeFocused}
href={explorerUrl(`users/${didFromPublicKey(config.publicKey)}`)}
href={explorerUrl(
`users/${didFromPublicKey(config.publicKey)}`,
config,
)}
target="_blank">
<DropdownListItem styleGap="0.5rem" styleWidth="100%" selected={false}>
<Icon name="seed" />
view on iris.radicle.network
Open in {explorerHost(config)}
<span style:margin-left="auto"><Icon name="open-external" /></span>
</DropdownListItem>
</a>
+5 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Config } from "@bindings/config/Config";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import debounce from "lodash/debounce";
@@ -15,9 +16,10 @@
interface Props {
repo: RepoInfo;
config: Config;
}
const { repo }: Props = $props();
const { repo, config }: Props = $props();
const project = $derived(repo.payloads["xyz.radicle.project"]!);
@@ -27,7 +29,7 @@
}, 1000);
async function copyLink() {
await writeToClipboard(explorerUrl(repo.rid));
await writeToClipboard(explorerUrl(repo.rid, config));
copyIcon = "checkmark";
restoreCopyIcon();
}
@@ -131,7 +133,7 @@
style:white-space="nowrap"
style:text-decoration="none"
style:width="100%"
href={explorerUrl(`users/${delegate.did}`)}
href={explorerUrl(`users/${delegate.did}`, config)}
target="_blank">
{#if delegate.alias}
<span class="txt-overflow alias">
+11 -4
View File
@@ -37,6 +37,7 @@
</script>
<script lang="ts">
import type { Config } from "@bindings/config/Config";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { RepoSummary } from "@bindings/repo/RepoSummary";
@@ -53,7 +54,11 @@
writeToClipboard,
} from "@app/lib/invoke";
import * as router from "@app/lib/router";
import { explorerUrl, formatRepositoryId } from "@app/lib/utils";
import {
explorerHost,
explorerUrl,
formatRepositoryId,
} from "@app/lib/utils";
import AddRepoButton from "@app/components/AddRepoButton.svelte";
import ContextMenu from "@app/components/ContextMenu.svelte";
@@ -69,12 +74,14 @@
initialRepos: RepoSummary[];
initialSeededNotReplicated: string[];
activeRepo?: RepoInfo;
config: Config;
}
const {
initialRepos,
initialSeededNotReplicated,
activeRepo = undefined,
config,
}: Props = $props();
let repos: RepoSummary[] = $derived(initialRepos);
@@ -716,7 +723,7 @@
{#if contextMenu}
{@const repo = contextMenu.repo}
{@const url = explorerUrl(repo.rid)}
{@const url = explorerUrl(repo.rid, config)}
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
@@ -741,7 +748,7 @@
role="menuitem"
onclick={() => writeToClipboard(url)}>
<Icon name="link" />
Copy link to radicle.network
Copy link to {explorerHost(config)}
</button>
<a
class="menu-item"
@@ -750,7 +757,7 @@
target="_blank"
rel="noreferrer noopener">
<Icon name="open-external" />
Open on radicle.network
Open in {explorerHost(config)}
</a>
</ContextMenu>
{/if}
+34 -6
View File
@@ -2,6 +2,7 @@ import type { Author } from "@bindings/cob/Author";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { Review } from "@bindings/cob/patch/Review";
import type { Config } from "@bindings/config/Config";
import type { ComponentProps } from "svelte";
import bs58 from "bs58";
@@ -288,10 +289,37 @@ export function safeHttpUrl(url: string): string | undefined {
return undefined;
}
export function explorerUrl(
path: string,
seed = "iris.radicle.network",
explorer = "https://radicle.network",
) {
return `${explorer}/nodes/${seed}/${path}`;
// Seed to fall back to when `config.preferredSeeds` is empty.
const DEFAULT_SEED = "rosa.radicle.network";
// The host of the first entry in `config.preferredSeeds`, if any, parsed from
// its "<NID>@<host>:<port>" address form. Used to pick a seed that's actually
// known to replicate the user's repos, instead of a hardcoded default.
function preferredSeedHost(config: Config): string | undefined {
const address = config.preferredSeeds[0];
if (!address) {
return undefined;
}
return address.split("@")[1]?.split(":")[0] ?? address;
}
// The hostname of the configured public explorer, e.g. "radicle.network".
// Used to label links with the site they actually open.
export function explorerHost(config: Config): string {
try {
return new URL(config.publicExplorer).host;
} catch {
return config.publicExplorer;
}
}
// Build an explorer URL, honouring the user's configured `publicExplorer`
// template and preferred seed. The template mirrors radicle's `Explorer`
// type: "<base>/nodes/$host/$rid$path". `path` is everything after the seed
// host, e.g. `<rid>/issues/<id>` or `users/<did>`.
export function explorerUrl(path: string, config: Config): string {
const seed = preferredSeedHost(config) ?? DEFAULT_SEED;
return config.publicExplorer
.replace("$host", seed)
.replace("$rid$path", path);
}
+3 -2
View File
@@ -16,6 +16,7 @@
import * as roles from "@app/lib/roles";
import * as router from "@app/lib/router";
import {
explorerHost,
explorerUrl,
issueStatusBackgroundColor,
issueStatusColor,
@@ -324,8 +325,8 @@
<Icon name="chevron-right" />
<Id id={issue.id} clipboard={issue.id} placement="bottom-start" />
<ExternalLink
href={explorerUrl(`${repo.rid}/issues/${issue.id}`)}
title="Open in radicle.network" />
href={explorerUrl(`${repo.rid}/issues/${issue.id}`, config)}
title={`Open in ${explorerHost(config)}`} />
</div>
<div style:margin-left="auto">
<Button
+3 -2
View File
@@ -18,6 +18,7 @@
import * as router from "@app/lib/router";
import {
didFromPublicKey,
explorerHost,
explorerUrl,
patchStatusBackgroundColor,
patchStatusColor,
@@ -361,8 +362,8 @@
<Icon name="chevron-right" />
<Id id={patch.id} clipboard={patch.id} placement="bottom-start" />
<ExternalLink
href={explorerUrl(`${repo.rid}/patches/${patch.id}`)}
title="Open in radicle.network" />
href={explorerUrl(`${repo.rid}/patches/${patch.id}`, config)}
title={`Open in ${explorerHost(config)}`} />
</div>
<div style:margin-left="auto">
<NewPatchButton rid={repo.rid} ghost />
+9 -3
View File
@@ -13,8 +13,10 @@
import { diffOptions } from "@app/lib/diffOptions.svelte";
import { getDiffText, invoke } from "@app/lib/invoke";
import * as router from "@app/lib/router";
import type { SidebarData } from "@app/lib/router/definitions";
import {
absoluteTimestamp,
explorerHost,
explorerUrl,
formatOid,
formatTimestamp,
@@ -43,9 +45,10 @@
// Unified patch text feeding the rendered diff (Pierre renders from this,
// not from the structured `diff`).
patch: string;
sidebarData: SidebarData;
}
const { repo, commit, diff, patch }: Props = $props();
const { repo, commit, diff, patch, sidebarData }: Props = $props();
let diffView = $state<{
scrollToFile: (path: string) => void;
@@ -373,8 +376,11 @@
<Icon name="chevron-right" />
<Id id={commit.id} clipboard={commit.id} placement="bottom-start" />
<ExternalLink
href={explorerUrl(`${repo.rid}/commits/${commit.id}`)}
title="Open in app.radicle.xyz" />
href={explorerUrl(
`${repo.rid}/commits/${commit.id}`,
sidebarData.config,
)}
title={`Open in ${explorerHost(sidebarData.config)}`} />
</div>
<div class="topbar-right">
<Button
+5 -2
View File
@@ -9,6 +9,7 @@
import { invoke } from "@app/lib/invoke";
import { createPaginatedList } from "@app/lib/paginatedList.svelte";
import * as router from "@app/lib/router";
import type { SidebarData } from "@app/lib/router/definitions";
import { modifierKey } from "@app/lib/utils";
import CobCommitTeaser from "@app/components/CobCommitTeaser.svelte";
@@ -27,9 +28,11 @@
oid: string;
commit: Commit;
commits: PaginatedQuery<Commit[]>;
sidebarData: SidebarData;
}
const { repo, peer, revision, oid, commit, commits }: Props = $props();
const { repo, peer, revision, oid, commit, commits, sidebarData }: Props =
$props();
const baseRoute = $derived({
resource: "repo.commits" as const,
@@ -209,7 +212,7 @@
<Layout selfScroll>
<div class="page">
<RepoHeader {repo} />
<RepoHeader {repo} config={sidebarData.config} />
<SourceHeader
{repo}
{peer}
+5 -2
View File
@@ -10,6 +10,7 @@
import { useOverlayScrollbars } from "overlayscrollbars-svelte";
import { invoke, InvokeError } from "@app/lib/invoke";
import type { SidebarData } from "@app/lib/router/definitions";
import { highlight } from "@app/lib/syntax";
import FileBlock from "@app/components/FileBlock.svelte";
@@ -32,10 +33,12 @@
oid: string;
commit: Commit;
readme: Readme | null;
sidebarData: SidebarData;
}
/* eslint-disable prefer-const */
let { tree, readme, repo, peer, revision, oid, commit }: Props = $props();
let { tree, readme, repo, peer, revision, oid, commit, sidebarData }: Props =
$props();
/* eslint-enable prefer-const */
const baseRoute = $derived({
@@ -141,7 +144,7 @@
style:display="flex"
style:flex-direction="column"
style:height="100%">
<RepoHeader {repo} />
<RepoHeader {repo} config={sidebarData.config} />
<SourceHeader
{repo}
{peer}