Add share widget to repo/issue/patch pages

This replaces the open external icon button next to the ID in the
breadcrumbs as well as any copy link button.
This commit is contained in:
Rūdolfs Ošiņš
2026-07-24 15:48:26 +02:00
parent cb0c5c8a08
commit 883046269c
7 changed files with 231 additions and 44 deletions
+2 -2
View File
@@ -99,8 +99,8 @@
active={expanded}
{onclick}
title="Diff settings"
styleHeight="1.75rem"
styleWidth="1.75rem"
styleHeight="2rem"
styleWidth="2rem"
stylePadding="0"
styleJustifyContent="center">
<Icon name="settings" />
+6 -19
View File
@@ -2,15 +2,12 @@
import type { Config } from "@bindings/config/Config";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import debounce from "lodash/debounce";
import { writeToClipboard } from "@app/lib/invoke";
import { explorerUrl, truncateDid } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import CheckoutRepoButton from "@app/components/CheckoutRepoButton.svelte";
import HoverPopover from "@app/components/HoverPopover.svelte";
import Icon from "@app/components/Icon.svelte";
import ShareButton from "@app/components/ShareButton.svelte";
import UserAvatar from "@app/components/UserAvatar.svelte";
import VisibilityBadge from "@app/components/VisibilityBadge.svelte";
@@ -22,17 +19,6 @@
const { repo, config }: Props = $props();
const project = $derived(repo.payloads["xyz.radicle.project"]!);
let copyIcon: "link" | "checkmark" = $state("link");
const restoreCopyIcon = debounce(() => {
copyIcon = "link";
}, 1000);
async function copyLink() {
await writeToClipboard(explorerUrl(repo.rid, config));
copyIcon = "checkmark";
restoreCopyIcon();
}
</script>
<style>
@@ -156,10 +142,11 @@
</div>
<div class="actions">
<Button variant="ghost" styleHeight="2rem" onclick={copyLink}>
<Icon name={copyIcon} />
<span class="global-hide-on-medium-desktop-down">Copy Link</span>
</Button>
<ShareButton
explorerPath={repo.rid}
id={repo.rid}
idLabel="repository"
{config} />
<CheckoutRepoButton rid={repo.rid} />
</div>
</div>
+173
View File
@@ -0,0 +1,173 @@
<script lang="ts">
import type { Config } from "@bindings/config/Config";
import type { ComponentProps } from "svelte";
import debounce from "lodash/debounce";
import { writeToClipboard } from "@app/lib/invoke";
import {
defaultShareAction,
type ShareAction,
shareAction,
} from "@app/lib/shareAction.svelte";
import { explorerHost, explorerUrl } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import DropdownList from "@app/components/DropdownList.svelte";
import DropdownListItem from "@app/components/DropdownListItem.svelte";
import Icon from "@app/components/Icon.svelte";
import Popover, { closeFocused } from "@app/components/Popover.svelte";
type IconName = ComponentProps<typeof Icon>["name"];
interface Props {
explorerPath: string;
id: string;
// The noun the id represents, e.g. "issue", so "copy id" reads "Copy issue ID".
idLabel: string;
config: Config;
styleHeight?: ComponentProps<typeof Button>["styleHeight"];
}
const {
explorerPath,
id,
idLabel,
config,
styleHeight = "2rem",
}: Props = $props();
const url = $derived(explorerUrl(explorerPath, config));
const host = $derived(explorerHost(config));
const actions = $derived<
{ kind: ShareAction; icon: IconName; label: string; title: string }[]
>([
{
kind: "open",
icon: "open-external",
label: "Open on web",
title: `Open on ${host}`,
},
{
kind: "copyLink",
icon: "link",
label: "Copy link",
title: `Copy link to ${host}`,
},
{
kind: "copyId",
icon: "copy",
label: "Copy ID",
title: `Copy ${idLabel} ID`,
},
]);
// The lookup always matches; the fallback only narrows away `undefined`.
const selected = $derived(
actions.find(a => a.kind === shareAction.value) ??
actions.find(a => a.kind === defaultShareAction)!,
);
let popoverExpanded: boolean = $state(false);
let copied: boolean = $state(false);
const restore = debounce(() => {
copied = false;
}, 1000);
let anchorEl: HTMLAnchorElement | undefined = $state();
async function run(kind: ShareAction) {
if (kind === "open") {
anchorEl?.click();
return;
}
try {
await writeToClipboard(kind === "copyLink" ? url : id);
copied = true;
restore();
} catch {
// Ignore clipboard failures; there's nothing useful to show the user.
}
}
</script>
<style>
/* The 1px gap reveals the container's background as a divider between the two
buttons, which relies on the buttons having an opaque fill (ghost). */
.split {
display: inline-flex;
gap: 1px;
background-color: var(--color-border-subtle);
border-radius: var(--border-radius-sm);
}
</style>
<!-- Hidden anchor used for the "open" action so Tauri's shell plugin opens the
link in the system browser, matching how ExternalLink behaves. -->
<a
bind:this={anchorEl}
href={url}
target="_blank"
rel="noreferrer"
style:display="none"
tabindex="-1"
aria-hidden="true">
</a>
<div class="split">
<Button
variant="ghost"
{styleHeight}
flatRight
stylePadding="0 0.75rem"
title={selected.title}
onclick={() => run(selected.kind)}>
<Icon
name={copied && selected.kind !== "open" ? "checkmark" : selected.icon} />
<span class="global-hide-on-medium-desktop-down">{selected.label}</span>
</Button>
<Popover
popoverPadding="0"
placement="bottom-end"
bind:expanded={popoverExpanded}>
{#snippet toggle(onclick)}
<Button
variant="ghost"
{styleHeight}
styleWidth={styleHeight}
flatLeft
stylePadding="0"
active={popoverExpanded}
title="Share options"
{onclick}>
<Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
</Button>
{/snippet}
{#snippet popover()}
<div
style:border="1px solid var(--color-border-subtle)"
style:border-radius="var(--border-radius-sm)"
style:background-color="var(--color-surface-canvas)"
style:padding="0.25rem">
<DropdownList items={actions}>
{#snippet item(action)}
<DropdownListItem
selected={action.kind === selected.kind}
styleGap="0.5rem"
onclick={() => {
shareAction.value = action.kind;
closeFocused();
void run(action.kind);
}}>
<Icon name={action.icon} />
<span>{action.title}</span>
</DropdownListItem>
{/snippet}
</DropdownList>
</div>
{/snippet}
</Popover>
</div>
+28
View File
@@ -0,0 +1,28 @@
import { z } from "zod";
export const shareActionSchema = z.enum(["open", "copyLink", "copyId"]);
export type ShareAction = z.infer<typeof shareActionSchema>;
const KEY = "shareAction";
export const defaultShareAction: ShareAction = "copyLink";
function load(): ShareAction {
const raw = globalThis.localStorage?.getItem(KEY);
if (!raw) {
return defaultShareAction;
}
const parsed = shareActionSchema.safeParse(raw);
return parsed.success ? parsed.data : defaultShareAction;
}
export const shareAction = $state<{ value: ShareAction }>({ value: load() });
// A detached root effect persists changes for the whole session; the module
// singleton is never torn down.
$effect.root(() => {
$effect(() => {
globalThis.localStorage?.setItem(KEY, shareAction.value);
});
});
+7 -7
View File
@@ -16,8 +16,6 @@
import * as roles from "@app/lib/roles";
import * as router from "@app/lib/router";
import {
explorerHost,
explorerUrl,
issueStatusBackgroundColor,
issueStatusColor,
issueStatusLabel,
@@ -30,13 +28,13 @@
import CommentComponent from "@app/components/Comment.svelte";
import Discussion from "@app/components/Discussion.svelte";
import EditableTitle from "@app/components/EditableTitle.svelte";
import ExternalLink from "@app/components/ExternalLink.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import IssueStateButton from "@app/components/IssueStateButton.svelte";
import IssueTimeline from "@app/components/IssueTimeline.svelte";
import LabelInput from "@app/components/LabelInput.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import ShareButton from "@app/components/ShareButton.svelte";
import Topbar from "@app/components/Topbar.svelte";
import CreateIssueModal from "@app/modals/CreateIssue.svelte";
@@ -324,11 +322,13 @@
</button>
<Icon name="chevron-right" />
<Id id={issue.id} clipboard={issue.id} placement="bottom-start" />
<ExternalLink
href={explorerUrl(`${repo.rid}/issues/${issue.id}`, config)}
title={`Open in ${explorerHost(config)}`} />
</div>
<div style:margin-left="auto">
<div style:margin-left="auto" style:display="flex" style:gap="0.5rem">
<ShareButton
explorerPath={`${repo.rid}/issues/${issue.id}`}
id={issue.id}
idLabel="issue"
{config} />
<Button
styleHeight="2rem"
variant="ghost"
+7 -7
View File
@@ -18,8 +18,6 @@
import * as router from "@app/lib/router";
import {
didFromPublicKey,
explorerHost,
explorerUrl,
patchStatusBackgroundColor,
patchStatusColor,
patchStatusLabel,
@@ -31,7 +29,6 @@
import CheckoutPatchButton from "@app/components/CheckoutPatchButton.svelte";
import CommentComponent from "@app/components/Comment.svelte";
import EditableTitle from "@app/components/EditableTitle.svelte";
import ExternalLink from "@app/components/ExternalLink.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import NewPatchButton from "@app/components/NewPatchButton.svelte";
@@ -41,6 +38,7 @@
import RevisionComponent from "@app/components/Revision.svelte";
import Revisions from "@app/components/Revisions.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import ShareButton from "@app/components/ShareButton.svelte";
import Topbar from "@app/components/Topbar.svelte";
import Layout from "./Layout.svelte";
@@ -361,11 +359,13 @@
</button>
<Icon name="chevron-right" />
<Id id={patch.id} clipboard={patch.id} placement="bottom-start" />
<ExternalLink
href={explorerUrl(`${repo.rid}/patches/${patch.id}`, config)}
title={`Open in ${explorerHost(config)}`} />
</div>
<div style:margin-left="auto">
<div style:margin-left="auto" style:display="flex" style:gap="0.5rem">
<ShareButton
explorerPath={`${repo.rid}/patches/${patch.id}`}
id={patch.id}
idLabel="patch"
{config} />
<NewPatchButton rid={repo.rid} ghost />
</div>
</Topbar>
+8 -9
View File
@@ -16,8 +16,6 @@
import type { SidebarData } from "@app/lib/router/definitions";
import {
absoluteTimestamp,
explorerHost,
explorerUrl,
formatOid,
formatTimestamp,
gravatarURL,
@@ -28,12 +26,12 @@
import DiffActions from "@app/components/DiffActions.svelte";
import DiffOptionsButton from "@app/components/DiffOptionsButton.svelte";
import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
import ExternalLink from "@app/components/ExternalLink.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import JobCob from "@app/components/JobCob.svelte";
import PierreDiff from "@app/components/PierreDiff.svelte";
import PierreTree from "@app/components/PierreTree.svelte";
import ShareButton from "@app/components/ShareButton.svelte";
import Topbar from "@app/components/Topbar.svelte";
import Layout from "./Layout.svelte";
@@ -375,14 +373,15 @@
</button>
<Icon name="chevron-right" />
<Id id={commit.id} clipboard={commit.id} placement="bottom-start" />
<ExternalLink
href={explorerUrl(
`${repo.rid}/commits/${commit.id}`,
sidebarData.config,
)}
title={`Open in ${explorerHost(sidebarData.config)}`} />
</div>
<div class="topbar-right">
<span style:display="inline-flex" style:margin-right="0.5rem">
<ShareButton
explorerPath={`${repo.rid}/commits/${commit.id}`}
id={commit.id}
idLabel="commit"
config={sidebarData.config} />
</span>
<Button
variant="naked"
onclick={() => {