mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-01 01:30:46 +08:00
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:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user