mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 07:59:30 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/j/62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa39a658e3 |
@@ -16,6 +16,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -194,15 +195,12 @@ export function DaemonPanel({
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = filtered.map((l) => l.raw).join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (await copyText(text)) {
|
||||
toast.success(
|
||||
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error("Failed to copy", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} else {
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
}, [filtered]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, Terminal } from "lucide-react";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { useLocale } from "../../i18n";
|
||||
|
||||
const INSTALL_CMD =
|
||||
@@ -62,12 +63,9 @@ function CommandBlock({
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
if (await copyText(cmd)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
// clipboard may be unavailable (insecure context) — silent no-op
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
60
packages/ui/lib/clipboard.ts
Normal file
60
packages/ui/lib/clipboard.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copy text to the clipboard, with a fallback for insecure contexts (plain http://).
|
||||
*
|
||||
* The async Clipboard API (`navigator.clipboard`) is only exposed in a secure
|
||||
* context — `https://` or `localhost`. On a plain `http://` origin it is
|
||||
* `undefined`, so `navigator.clipboard.writeText` throws and the copy silently
|
||||
* fails (the symptom behind self-hosted-over-http bug reports). When the secure
|
||||
* API is unavailable we fall back to a hidden `<textarea>` + the legacy
|
||||
* `document.execCommand('copy')`, which works in non-secure contexts.
|
||||
*
|
||||
* @returns `true` on success, `false` on failure. Callers should gate their
|
||||
* success side effects (toast, "copied" check state) on the return value and
|
||||
* surface an error when it is `false`.
|
||||
*/
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
// Preferred path: async Clipboard API (secure contexts only).
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Permission denied / document not focused / blocked — fall through to
|
||||
// the legacy path below rather than failing outright.
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: hidden textarea + execCommand('copy'). Works over plain http://.
|
||||
if (typeof document === "undefined") return false;
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
// Keep it visually hidden and out of layout/scroll flow.
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "0";
|
||||
textarea.style.width = "1px";
|
||||
textarea.style.height = "1px";
|
||||
textarea.style.padding = "0";
|
||||
textarea.style.border = "none";
|
||||
textarea.style.opacity = "0";
|
||||
textarea.style.pointerEvents = "none";
|
||||
|
||||
// Preserve focus so an open menu/popover that owns the copy button is not
|
||||
// disturbed by the temporary selection.
|
||||
const previouslyFocused =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
try {
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
previouslyFocused?.focus();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@multica/ui/components/ui/button"
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { copyText } from '../lib/clipboard'
|
||||
import {
|
||||
CODE_LIGATURE_CLASS,
|
||||
CODE_LIGATURE_DESCENDANT_CLASS,
|
||||
@@ -134,12 +135,9 @@ export function CodeBlock({
|
||||
}, [code, resolvedLang])
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
if (await copyText(code)) {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy code:', err)
|
||||
}
|
||||
}, [code])
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"./lib/utils": "./lib/utils.ts",
|
||||
"./lib/data-table": "./lib/data-table.ts",
|
||||
"./lib/code-style": "./lib/code-style.ts",
|
||||
"./lib/clipboard": "./lib/clipboard.ts",
|
||||
"./i18n-types": "./types/i18next.ts",
|
||||
"./styles/tokens.css": "./styles/tokens.css",
|
||||
"./styles/base.css": "./styles/base.css"
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -291,12 +292,11 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!webhookUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(webhookUrl);
|
||||
if (await copyText(webhookUrl)) {
|
||||
setCopied(true);
|
||||
toast.success(t(($) => $.trigger_row.url_copied));
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
} else {
|
||||
toast.error(t(($) => $.trigger_row.url_copy_failed));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -1114,12 +1115,11 @@ function WebhookCreatedPanel({
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
if (await copyText(url)) {
|
||||
setCopied(true);
|
||||
toast.success(t(($) => $.trigger_row.url_copied));
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
} else {
|
||||
toast.error(t(($) => $.trigger_row.url_copy_failed));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { toast } from "sonner";
|
||||
import { useT } from "../../i18n";
|
||||
import type {
|
||||
@@ -438,12 +439,11 @@ function CodeBlock({ label, value }: { label: string; value: string }) {
|
||||
const display = isTruncated ? value.slice(0, TRUNCATE_AT) : value;
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
if (await copyText(value)) {
|
||||
setCopied(true);
|
||||
toast.success(t(($) => $.webhook_payload.copied));
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
} else {
|
||||
toast.error(t(($) => $.webhook_payload.copy_failed));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useMemo } from "react";
|
||||
import { Webhook, ChevronDown, ChevronRight, Copy, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface WebhookPayloadPreviewProps {
|
||||
@@ -66,12 +67,11 @@ export function WebhookPayloadPreview({
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullJSON);
|
||||
if (await copyText(fullJSON)) {
|
||||
setCopied(true);
|
||||
toast.success(t(($) => $.webhook_payload.copied));
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
} else {
|
||||
toast.error(t(($) => $.webhook_payload.copy_failed));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import { ChevronRight, ChevronDown, Brain, AlertCircle, AlertTriangle, Copy } fr
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { isTaskMessageTaskId, taskMessagesOptions } from "@multica/core/chat/queries";
|
||||
import { Markdown } from "@multica/views/common/markdown";
|
||||
import { copyMarkdown } from "../../editor";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { AttachmentList } from "../../issues/components/comment-card";
|
||||
import type { AgentAvailability } from "@multica/core/agents";
|
||||
import type { ChatMessage, ChatPendingTask, TaskFailureReason } from "@multica/core/types";
|
||||
@@ -305,10 +305,9 @@ function MessageCopyButton({
|
||||
}) {
|
||||
const { t } = useT("chat");
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyMarkdown(extractCopyText(message, timeline));
|
||||
if (await copyText(extractCopyText(message, timeline))) {
|
||||
toast.success(t(($) => $.message_list.copied_toast));
|
||||
} catch {
|
||||
} else {
|
||||
toast.error(t(($) => $.message_list.copy_failed_toast));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ArrowUpNarrowWide,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@multica/ui/components/ui/collapsible";
|
||||
import {
|
||||
@@ -280,7 +281,8 @@ export function AgentTranscriptDialog({
|
||||
// sequence they see on screen — matters when sort is set to newest-first.
|
||||
const handleCopyWorkdir = useCallback(() => {
|
||||
if (!task.relative_work_dir) return;
|
||||
navigator.clipboard.writeText(task.relative_work_dir).then(() => {
|
||||
void copyText(task.relative_work_dir).then((ok) => {
|
||||
if (!ok) return;
|
||||
setCopiedWorkdir(true);
|
||||
setTimeout(() => setCopiedWorkdir(false), 2000);
|
||||
});
|
||||
@@ -294,7 +296,8 @@ export function AgentTranscriptDialog({
|
||||
return `[${label}] ${summary}`;
|
||||
})
|
||||
.join("\n");
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
void copyText(text).then((ok) => {
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import type { Attachment as AttachmentRecord } from "@multica/core/types";
|
||||
import { useT } from "../i18n";
|
||||
import { useAttachmentDownloadResolver } from "./attachment-download-context";
|
||||
@@ -250,10 +251,9 @@ function ImageAttachmentView({
|
||||
const { t } = useT("editor");
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(src);
|
||||
if (await copyText(src)) {
|
||||
toast.success(t(($) => $.image.link_copied));
|
||||
} catch {
|
||||
} else {
|
||||
toast.error(t(($) => $.image.copy_link_failed));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { Code as CodeIcon, Copy, Check, Eye } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { useT } from "../../i18n";
|
||||
import { MermaidDiagram } from "../mermaid-diagram";
|
||||
import { CodeBlockIframe } from "../code-block-iframe";
|
||||
@@ -53,9 +54,10 @@ function CodeBlockView({ node }: NodeViewProps) {
|
||||
const handleCopy = async () => {
|
||||
const text = node.textContent;
|
||||
if (!text) return;
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
if (await copyText(text)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const showHtmlPreview = isHtml && view === "preview";
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -52,13 +53,9 @@ export function HtmlBlockPreview({ html, className }: HtmlBlockPreviewProps) {
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!html) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(html);
|
||||
if (await copyText(html)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard failures are user-recoverable (click again, or copy
|
||||
// manually from the source view) — no need for a toast here.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ export {
|
||||
type TitleEditorProps,
|
||||
type TitleEditorRef,
|
||||
} from "./title-editor";
|
||||
export { copyMarkdown } from "./utils/clipboard";
|
||||
export { ReadonlyContent } from "./readonly-content";
|
||||
export { useFileDropZone } from "./use-file-drop-zone";
|
||||
export { FileDropOverlay } from "./file-drop-overlay";
|
||||
|
||||
@@ -15,6 +15,7 @@ import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import { ExternalLink, Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { useWorkspaceSlug } from "@multica/core/paths";
|
||||
import { useT } from "../i18n";
|
||||
import { openLink, isMentionHref } from "./utils/link-handler";
|
||||
@@ -170,10 +171,9 @@ function LinkHoverCard({
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
try {
|
||||
await navigator.clipboard.writeText(href);
|
||||
if (await copyText(href)) {
|
||||
toast.success(t(($) => $.link_hover.link_copied));
|
||||
} catch {
|
||||
} else {
|
||||
toast.error(t(($) => $.link_hover.copy_failed));
|
||||
}
|
||||
};
|
||||
|
||||
73
packages/views/editor/utils/clipboard.test.ts
Normal file
73
packages/views/editor/utils/clipboard.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
|
||||
// jsdom implements neither navigator.clipboard nor document.execCommand, so we
|
||||
// define them per test to simulate secure (https/localhost) vs insecure (plain
|
||||
// http://) contexts.
|
||||
function setClipboard(value: unknown): void {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function setExecCommand(result: boolean): ReturnType<typeof vi.fn> {
|
||||
const mock = vi.fn().mockReturnValue(result);
|
||||
Object.defineProperty(document, "execCommand", {
|
||||
value: mock,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setClipboard(undefined);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("copyText", () => {
|
||||
it("uses the async Clipboard API in a secure context and returns true", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
setClipboard({ writeText });
|
||||
|
||||
const ok = await copyText("hello");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("falls back to execCommand when navigator.clipboard is unavailable (http://)", async () => {
|
||||
setClipboard(undefined);
|
||||
const execCommand = setExecCommand(true);
|
||||
|
||||
const ok = await copyText("from-http");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
// The temporary textarea must be cleaned up afterwards.
|
||||
expect(document.querySelector("textarea")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to execCommand when writeText rejects", async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error("blocked"));
|
||||
setClipboard({ writeText });
|
||||
const execCommand = setExecCommand(true);
|
||||
|
||||
const ok = await copyText("retry");
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("retry");
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
});
|
||||
|
||||
it("returns false when the fallback execCommand fails", async () => {
|
||||
setClipboard(undefined);
|
||||
setExecCommand(false);
|
||||
|
||||
const ok = await copyText("nope");
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Copy markdown content to the clipboard.
|
||||
*/
|
||||
export async function copyMarkdown(markdown: string): Promise<void> {
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSeparator,
|
||||
} from "@multica/ui/components/ui/context-menu";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import type { UseIssueActionsResult } from "./use-issue-actions";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
@@ -132,10 +133,10 @@ export function IssueActionsMenuItems({
|
||||
toast.error(t(($) => $.detail.workdir_path_unavailable));
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(latestWorkDir).then(
|
||||
() => toast.success(t(($) => $.detail.workdir_path_copied)),
|
||||
() => toast.error(t(($) => $.detail.workdir_path_copy_failed)),
|
||||
);
|
||||
void copyText(latestWorkDir).then((ok) => {
|
||||
if (ok) toast.success(t(($) => $.detail.workdir_path_copied));
|
||||
else toast.error(t(($) => $.detail.workdir_path_copy_failed));
|
||||
});
|
||||
}, [tasks, t]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { pinListOptions, useCreatePin, useDeletePin } from "@multica/core/pins";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
@@ -101,10 +102,9 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
|
||||
const copyLink = useCallback(async () => {
|
||||
if (!issueId) return;
|
||||
const url = navigation.getShareableUrl(paths.issueDetail(issueId));
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
if (await copyText(url)) {
|
||||
toast.success(t(($) => $.detail.link_copied));
|
||||
} catch {
|
||||
} else {
|
||||
toast.error(t(($) => $.detail.link_copy_failed));
|
||||
}
|
||||
}, [paths, issueId, navigation, t]);
|
||||
|
||||
@@ -28,9 +28,10 @@ import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { ReactionBar } from "@multica/ui/components/common/reaction-bar";
|
||||
import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-picker";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { useTimeAgo } from "../../i18n";
|
||||
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, Attachment as AttachmentRenderer, AttachmentDownloadProvider } from "../../editor";
|
||||
import { ContentEditor, type ContentEditorRef, ReadonlyContent, useFileDropZone, FileDropOverlay, Attachment as AttachmentRenderer, AttachmentDownloadProvider } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
@@ -381,8 +382,9 @@ function CommentRow({
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
copyMarkdown(entry.content ?? "");
|
||||
toast.success(t(($) => $.comment.copied_toast));
|
||||
void copyText(entry.content ?? "").then((ok) => {
|
||||
if (ok) toast.success(t(($) => $.comment.copied_toast));
|
||||
});
|
||||
}}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.copy_action)}
|
||||
@@ -595,8 +597,9 @@ function CommentCardImpl({
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
copyMarkdown(entry.content ?? "");
|
||||
toast.success(t(($) => $.comment.copied_toast));
|
||||
void copyText(entry.content ?? "").then((ok) => {
|
||||
if (ok) toast.success(t(($) => $.comment.copied_toast));
|
||||
});
|
||||
}}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.copy_action)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Check, Copy, Terminal } from "lucide-react";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { CODE_LIGATURE_CLASS } from "@multica/ui/lib/code-style";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const INSTALL_CMD =
|
||||
@@ -16,9 +17,11 @@ function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
void copyText(text).then((ok) => {
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
|
||||
import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, PanelRight, Pin, PinOff, Plus, Trash2, UserMinus } from "lucide-react";
|
||||
import { useQuery, type QueryKey } from "@tanstack/react-query";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { toast } from "sonner";
|
||||
import type { Issue, IssueAssigneeGroup, ProjectStatus, ProjectPriority, UpdateIssueRequest } from "@multica/core/types";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -754,8 +755,9 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
toast.success(t(($) => $.detail.toast_link_copied));
|
||||
void copyText(window.location.href).then((ok) => {
|
||||
if (ok) toast.success(t(($) => $.detail.toast_link_copied));
|
||||
});
|
||||
}}>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.detail.copy_link)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { CODE_LIGATURE_CLASS } from "@multica/ui/lib/code-style";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import { useT } from "../../i18n";
|
||||
@@ -127,8 +128,9 @@ function CopyButton({ text, ariaLabel }: { text: string; ariaLabel: string }) {
|
||||
}, [copied]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
void copyText(text).then((ok) => {
|
||||
if (ok) setCopied(true);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
DialogDescription,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { useTheme } from "@multica/ui/components/common/theme-provider";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useT } from "../i18n";
|
||||
import { matchesPinyin } from "../editor/extensions/pinyin-match";
|
||||
@@ -221,8 +222,9 @@ export function SearchCommand() {
|
||||
icon: Link2,
|
||||
keywords: ["copy", "link", "share", "url", identifier.toLowerCase()],
|
||||
onSelect: () => {
|
||||
void navigator.clipboard.writeText(getShareableUrl(pathname));
|
||||
toast.success(t(($) => $.toast.link_copied));
|
||||
void copyText(getShareableUrl(pathname)).then((ok) => {
|
||||
if (ok) toast.success(t(($) => $.toast.link_copied));
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
@@ -232,8 +234,9 @@ export function SearchCommand() {
|
||||
icon: Copy,
|
||||
keywords: ["copy", "id", "identifier", identifier.toLowerCase()],
|
||||
onSelect: () => {
|
||||
void navigator.clipboard.writeText(identifier);
|
||||
toast.success(t(($) => $.toast.copied_identifier, { identifier }));
|
||||
void copyText(identifier).then((ok) => {
|
||||
if (ok) toast.success(t(($) => $.toast.copied_identifier, { identifier }));
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useT } from "../../i18n";
|
||||
@@ -95,9 +96,10 @@ export function TokensTab() {
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (!newToken) return;
|
||||
await navigator.clipboard.writeText(newToken);
|
||||
setTokenCopied(true);
|
||||
setTimeout(() => setTokenCopied(false), 2000);
|
||||
if (await copyText(newToken)) {
|
||||
setTokenCopied(true);
|
||||
setTimeout(() => setTokenCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user