refactor: unify clipboard copy flow across desktop/web/vscode runtimes (#458)
* fix: prevent copying incomplete diagnostics report * refactoring: clipboard writes to unified cross-runtime fallback helper
This commit is contained in:
committed by
GitHub
parent
881e8bdf4f
commit
47cecfc356
@@ -25,6 +25,7 @@ import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import type { TurnGroupingContext } from './hooks/useTurnGrouping';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
|
||||
|
||||
@@ -578,9 +579,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
}, [isUser, previousRole, turnGroupingContext, streamPhase, message.info]);
|
||||
|
||||
const handleCopyCode = React.useCallback((code: string) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopiedCode(code);
|
||||
setTimeout(() => setCopiedCode(null), 2000);
|
||||
void copyTextToClipboard(code).then((result) => {
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
setCopiedCode(code);
|
||||
setTimeout(() => setCopiedCode(null), 2000);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const userMessageIdForTurn = turnGroupingContext?.turnId;
|
||||
@@ -704,46 +709,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const hasTextContent = messageTextContent.length > 0;
|
||||
|
||||
const copyTextToClipboard = React.useCallback(async (text: string): Promise<boolean> => {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof window !== 'undefined' && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
void error;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-1000px';
|
||||
textarea.style.left = '-1000px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const succeeded = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return succeeded;
|
||||
}, []);
|
||||
|
||||
const handleCopyMessage = React.useCallback(async () => {
|
||||
const didCopy = await copyTextToClipboard(messageTextContent);
|
||||
if (!didCopy) {
|
||||
const result = await copyTextToClipboard(messageTextContent);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
setCopiedMessage(true);
|
||||
setTimeout(() => setCopiedMessage(false), 2000);
|
||||
}, [copyTextToClipboard, messageTextContent]);
|
||||
}, [messageTextContent]);
|
||||
|
||||
const handleRevert = React.useCallback(() => {
|
||||
if (!sessionId || !message.info.id) return;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
@@ -262,9 +263,10 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
|
||||
const tableEl = tableRef.current?.querySelector('table');
|
||||
if (!tableEl) return;
|
||||
|
||||
const data = extractTableData(tableEl);
|
||||
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
|
||||
|
||||
try {
|
||||
const data = extractTableData(tableEl);
|
||||
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'text/plain': new Blob([content], { type: 'text/plain' }),
|
||||
@@ -275,6 +277,13 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
|
||||
setShowMenu(false);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
const fallbackResult = await copyTextToClipboard(content);
|
||||
if (fallbackResult.ok) {
|
||||
setCopied(true);
|
||||
setShowMenu(false);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
return;
|
||||
}
|
||||
console.error('Failed to copy table:', err);
|
||||
}
|
||||
};
|
||||
@@ -481,12 +490,12 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
|
||||
const handleCopy = async () => {
|
||||
const code = getCodeContent();
|
||||
if (!code) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
const result = await copyTextToClipboard(code);
|
||||
if (result.ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
} else {
|
||||
console.error('Failed to copy:', result.error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -539,12 +548,12 @@ const MermaidCopyButton: React.FC<{ source: string }> = ({ source }) => {
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!source) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(source);
|
||||
const result = await copyTextToClipboard(source);
|
||||
if (result.ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy diagram:', err);
|
||||
} else {
|
||||
console.error('Failed to copy diagram:', result.error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executi
|
||||
import { useMessageTTS } from '@/hooks/useMessageTTS';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { TextSelectionMenu } from './TextSelectionMenu';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
type SubtaskPartLike = Part & {
|
||||
type: 'subtask';
|
||||
@@ -165,32 +166,8 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
|
||||
const copyOutputToClipboard = React.useCallback(async () => {
|
||||
if (!hasOutput) return;
|
||||
|
||||
let succeeded = false;
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof window !== 'undefined' && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(output);
|
||||
succeeded = true;
|
||||
} catch {
|
||||
succeeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!succeeded && typeof document !== 'undefined') {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = output;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-1000px';
|
||||
textarea.style.left = '-1000px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
succeeded = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
if (!succeeded) return;
|
||||
const result = await copyTextToClipboard(output);
|
||||
if (!result.ok) return;
|
||||
|
||||
clearCopiedResetTimeout();
|
||||
setCopiedOutput(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
interface TextSelectionMenuProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
@@ -223,10 +224,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
if (!selectedText) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedText);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
const result = await copyTextToClipboard(selectedText);
|
||||
if (!result.ok) {
|
||||
console.error('Failed to copy:', result.error);
|
||||
}
|
||||
|
||||
hideMenu();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { toast } from '@/components/ui';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, openDesktopPath, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop';
|
||||
import { RiArrowDownSLine, RiCheckLine, RiFileCopyLine, RiRefreshLine } from '@remixicon/react';
|
||||
@@ -302,29 +303,9 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
|
||||
};
|
||||
|
||||
const handleCopyPath = async () => {
|
||||
if (typeof navigator === 'undefined') return;
|
||||
const text = directory;
|
||||
let copied = false;
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = true;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
if (!copied) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'absolute';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
copied = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
if (!copied) {
|
||||
const result = await copyTextToClipboard(text);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
toast.success('Path copied to clipboard');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
type SessionMessage = { info: Message; parts: Part[] };
|
||||
|
||||
@@ -303,8 +304,8 @@ export const ContextPanelContent: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const handleCopyRawMessage = React.useCallback(async (messageId: string, value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
const result = await copyTextToClipboard(value);
|
||||
if (result.ok) {
|
||||
setCopiedRawMessageId(messageId);
|
||||
if (copyResetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copyResetTimeoutRef.current);
|
||||
@@ -313,7 +314,7 @@ export const ContextPanelContent: React.FC = () => {
|
||||
setCopiedRawMessageId((prev) => (prev === messageId ? null : prev));
|
||||
copyResetTimeoutRef.current = null;
|
||||
}, 2000);
|
||||
} catch {
|
||||
} else {
|
||||
setCopiedRawMessageId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -45,6 +45,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
|
||||
@@ -286,8 +287,13 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
)}
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void navigator.clipboard.writeText(node.path);
|
||||
toast.success('Path copied');
|
||||
void copyTextToClipboard(node.path).then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success('Path copied');
|
||||
return;
|
||||
}
|
||||
toast.error('Copy failed');
|
||||
});
|
||||
}}>
|
||||
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
@@ -146,12 +147,12 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
||||
}, [opencodeBinary]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(INSTALL_COMMAND);
|
||||
const result = await copyTextToClipboard(INSTALL_COMMAND);
|
||||
if (result.ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
} else {
|
||||
console.error('Failed to copy:', result.error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { toast } from '@/components/ui';
|
||||
import { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine } from '@remixicon/react';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
|
||||
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
|
||||
@@ -448,23 +449,23 @@ export const ProvidersPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleCopyOAuthLink = async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
const result = await copyTextToClipboard(url);
|
||||
if (result.ok) {
|
||||
toast.success('OAuth link copied');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy OAuth link:', error);
|
||||
toast.error('Failed to copy OAuth link');
|
||||
return;
|
||||
}
|
||||
console.error('Failed to copy OAuth link:', result.error);
|
||||
toast.error('Failed to copy OAuth link');
|
||||
};
|
||||
|
||||
const handleCopyOAuthCode = async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
const result = await copyTextToClipboard(code);
|
||||
if (result.ok) {
|
||||
toast.success('Device code copied');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy device code:', error);
|
||||
toast.error('Failed to copy device code');
|
||||
return;
|
||||
}
|
||||
console.error('Failed to copy device code:', result.error);
|
||||
toast.error('Failed to copy device code');
|
||||
};
|
||||
|
||||
const handleDisconnectProvider = async (providerId: string) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import {
|
||||
DndContext,
|
||||
@@ -1039,9 +1040,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
);
|
||||
|
||||
const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => {
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
void copyTextToClipboard(url)
|
||||
.then((result) => {
|
||||
if (!result.ok) {
|
||||
toast.error('Failed to copy URL');
|
||||
return;
|
||||
}
|
||||
setCopiedSessionId(sessionId);
|
||||
if (copyTimeout.current) {
|
||||
clearTimeout(copyTimeout.current);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isMobileDeviceViaCSS } from '@/lib/device';
|
||||
import type { TerminalTheme } from '@/lib/terminalTheme';
|
||||
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
||||
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
|
||||
@@ -391,29 +392,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
const copied = await navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (copied) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await copyTextToClipboard(text);
|
||||
}, [getDomSelectionTextInViewport, getTerminalSelectionText]);
|
||||
|
||||
const hasCopyableSelectionInViewport = React.useCallback((): boolean => {
|
||||
|
||||
@@ -23,18 +23,29 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
|
||||
const [version, setVersion] = React.useState<string | null>(null);
|
||||
const [isCopyingDiagnostics, setIsCopyingDiagnostics] = React.useState(false);
|
||||
const [copiedDiagnostics, setCopiedDiagnostics] = React.useState(false);
|
||||
const [diagnosticsReport, setDiagnosticsReport] = React.useState<string | null>(null);
|
||||
const [isPreparingDiagnostics, setIsPreparingDiagnostics] = React.useState(false);
|
||||
|
||||
const handleCopyDiagnostics = React.useCallback(async () => {
|
||||
if (isCopyingDiagnostics) return;
|
||||
setIsCopyingDiagnostics(true);
|
||||
setCopiedDiagnostics(false);
|
||||
try {
|
||||
const result = await debugUtils.copyDiagnosticsReport();
|
||||
if (!diagnosticsReport) {
|
||||
toast.error('Copy failed', {
|
||||
description: 'Diagnostics not ready yet. Wait a second and retry.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await debugUtils.copyTextToClipboard(diagnosticsReport);
|
||||
if (result.ok) {
|
||||
setCopiedDiagnostics(true);
|
||||
toast.success('Diagnostics copied');
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
toast.error('Copy failed', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Copy failed');
|
||||
@@ -42,7 +53,7 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
|
||||
} finally {
|
||||
setIsCopyingDiagnostics(false);
|
||||
}
|
||||
}, [isCopyingDiagnostics]);
|
||||
}, [diagnosticsReport, isCopyingDiagnostics]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -65,6 +76,35 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setDiagnosticsReport(null);
|
||||
setIsPreparingDiagnostics(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsPreparingDiagnostics(true);
|
||||
void debugUtils.buildDiagnosticsReport()
|
||||
.then((report) => {
|
||||
if (cancelled) return;
|
||||
setDiagnosticsReport(report);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
console.error('Failed to prepare diagnostics:', error);
|
||||
setDiagnosticsReport(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setIsPreparingDiagnostics(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const displayVersion = version;
|
||||
|
||||
return (
|
||||
@@ -98,14 +138,18 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
|
||||
<div className="flex flex-col items-center gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleCopyDiagnostics}
|
||||
disabled={isCopyingDiagnostics}
|
||||
disabled={isCopyingDiagnostics || isPreparingDiagnostics || !diagnosticsReport}
|
||||
className={cn(
|
||||
'typography-meta text-muted-foreground hover:text-foreground',
|
||||
'underline-offset-2 hover:underline',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{copiedDiagnostics ? 'Diagnostics copied' : 'Copy diagnostics'}
|
||||
{copiedDiagnostics
|
||||
? 'Diagnostics copied'
|
||||
: isPreparingDiagnostics
|
||||
? 'Preparing diagnostics...'
|
||||
: 'Copy diagnostics'}
|
||||
</button>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Includes OpenChamber state, OpenCode health, directories, and projects.
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { RiErrorWarningLine, RiRestartLine } from '@remixicon/react';
|
||||
import { Button } from './button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './card';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
@@ -41,14 +42,12 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
|
||||
const componentStack = this.state.errorInfo?.componentStack ? `\n\nComponent stack:${this.state.errorInfo.componentStack}` : '';
|
||||
const payload = `${errorText}${stack}${componentStack}`;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(payload);
|
||||
const result = await copyTextToClipboard(payload);
|
||||
if (result.ok) {
|
||||
this.setState({ copied: true });
|
||||
window.setTimeout(() => {
|
||||
this.setState((prev) => (prev.copied ? { copied: false } : null));
|
||||
}, 1500);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
export const OpenCodeStatusDialog: React.FC = () => {
|
||||
const {
|
||||
@@ -16,19 +17,17 @@ export const OpenCodeStatusDialog: React.FC = () => {
|
||||
openCodeStatusText,
|
||||
} = useUIStore();
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
if (!openCodeStatusText) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard
|
||||
.writeText(openCodeStatusText)
|
||||
.then(() => {
|
||||
toast.success('Copied', { description: 'OpenCode status copied to clipboard.' });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Copy failed');
|
||||
});
|
||||
const result = await copyTextToClipboard(openCodeStatusText);
|
||||
if (result.ok) {
|
||||
toast.success('Copied', { description: 'OpenCode status copied to clipboard.' });
|
||||
return;
|
||||
}
|
||||
toast.error('Copy failed');
|
||||
}, [openCodeStatusText]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { RiCheckLine, RiClipboardLine, RiDownloadCloudLine, RiDownloadLine, RiExternalLinkLine, RiLoaderLine, RiRestartLine, RiTerminalLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error';
|
||||
|
||||
@@ -172,11 +173,11 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
||||
}, [open]);
|
||||
|
||||
const handleCopyCommand = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(updateCommand);
|
||||
const result = await copyTextToClipboard(updateCommand);
|
||||
if (result.ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
} else {
|
||||
// Clipboard access denied
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { isValidElement } from "react"
|
||||
import { toast as sonnerToast } from "sonner"
|
||||
import type { ExternalToast } from "sonner"
|
||||
import { copyTextToClipboard } from '@/lib/clipboard'
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err)
|
||||
const result = await copyTextToClipboard(text)
|
||||
if (!result.ok) {
|
||||
console.error('Failed to copy to clipboard:', result.error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
RiFileCopyLine,
|
||||
} from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -447,8 +448,13 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
)}
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void navigator.clipboard.writeText(node.path);
|
||||
toast.success('Path copied');
|
||||
void copyTextToClipboard(node.path).then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success('Path copied');
|
||||
return;
|
||||
}
|
||||
toast.error('Copy failed');
|
||||
});
|
||||
}}>
|
||||
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
|
||||
</DropdownMenuItem>
|
||||
@@ -2049,8 +2055,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fileContent);
|
||||
const result = await copyTextToClipboard(fileContent);
|
||||
if (result.ok) {
|
||||
setCopiedContent(true);
|
||||
if (copiedContentTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedContentTimeoutRef.current);
|
||||
@@ -2058,7 +2064,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
copiedContentTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedContent(false);
|
||||
}, 1200);
|
||||
} catch {
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}}
|
||||
@@ -2079,8 +2085,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(displaySelectedPath);
|
||||
const result = await copyTextToClipboard(displaySelectedPath);
|
||||
if (result.ok) {
|
||||
setCopiedPath(true);
|
||||
if (copiedPathTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedPathTimeoutRef.current);
|
||||
@@ -2088,7 +2094,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
copiedPathTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedPath(false);
|
||||
}, 1200);
|
||||
} catch {
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}}
|
||||
@@ -2437,8 +2443,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fileContent);
|
||||
const result = await copyTextToClipboard(fileContent);
|
||||
if (result.ok) {
|
||||
setCopiedContent(true);
|
||||
if (copiedContentTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedContentTimeoutRef.current);
|
||||
@@ -2446,7 +2452,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
copiedContentTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedContent(false);
|
||||
}, 1200);
|
||||
} catch {
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}}
|
||||
@@ -2467,8 +2473,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(displaySelectedPath);
|
||||
const result = await copyTextToClipboard(displaySelectedPath);
|
||||
if (result.ok) {
|
||||
setCopiedPath(true);
|
||||
if (copiedPathTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedPathTimeoutRef.current);
|
||||
@@ -2476,7 +2482,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
copiedPathTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedPath(false);
|
||||
}, 1200);
|
||||
} catch {
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
||||
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import {
|
||||
useGitStore,
|
||||
useGitStatus,
|
||||
@@ -473,14 +474,13 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
const [stashDialogBranch, setStashDialogBranch] = React.useState('');
|
||||
|
||||
const handleCopyCommitHash = React.useCallback((hash: string) => {
|
||||
navigator.clipboard
|
||||
.writeText(hash)
|
||||
.then(() => {
|
||||
void copyTextToClipboard(hash).then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success('Commit hash copied');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to copy');
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.error('Failed to copy');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleCommit = React.useCallback((hash: string) => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
@@ -428,8 +429,8 @@ export const PlanView: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
const result = await copyTextToClipboard(content);
|
||||
if (result.ok) {
|
||||
setCopiedContent(true);
|
||||
if (copiedContentTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedContentTimeoutRef.current);
|
||||
@@ -437,7 +438,7 @@ export const PlanView: React.FC = () => {
|
||||
copiedContentTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedContent(false);
|
||||
}, 1200);
|
||||
} catch {
|
||||
} else {
|
||||
// ignored
|
||||
}
|
||||
}}
|
||||
@@ -455,8 +456,8 @@ export const PlanView: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayPath ?? resolvedPath);
|
||||
const result = await copyTextToClipboard(displayPath ?? resolvedPath);
|
||||
if (result.ok) {
|
||||
setCopiedPath(true);
|
||||
if (copiedTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedTimeoutRef.current);
|
||||
@@ -464,7 +465,7 @@ export const PlanView: React.FC = () => {
|
||||
copiedTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedPath(false);
|
||||
}, 1200);
|
||||
} catch {
|
||||
} else {
|
||||
// ignored
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore';
|
||||
@@ -88,14 +89,13 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
toast.error('No worktree path available');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard
|
||||
.writeText(selectedSession.path)
|
||||
.then(() => {
|
||||
void copyTextToClipboard(selectedSession.path).then((result) => {
|
||||
if (result.ok) {
|
||||
toast.success('Worktree path copied');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to copy path');
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.error('Failed to copy path');
|
||||
});
|
||||
}, [selectedSession?.path]);
|
||||
|
||||
const handleRemoveSelectedWorktree = React.useCallback(async () => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export type ClipboardCopyResult =
|
||||
| { ok: true; method: 'clipboard' | 'execCommand' }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<ClipboardCopyResult> {
|
||||
let clipboardError: string | null = null;
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return { ok: true, method: 'clipboard' };
|
||||
} catch (error) {
|
||||
clipboardError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined' && document.body) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-1000px';
|
||||
textarea.style.left = '-1000px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const copied = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
|
||||
if (copied) {
|
||||
return { ok: true, method: 'execCommand' };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: clipboardError ?? 'Clipboard access denied in current context',
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||
import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
export interface DebugMessageInfo {
|
||||
messageId: string;
|
||||
@@ -373,13 +374,14 @@ export const debugUtils = {
|
||||
return JSON.stringify(report, null, 2);
|
||||
},
|
||||
|
||||
async copyTextToClipboard(text: string) {
|
||||
return copyPlainTextToClipboard(text);
|
||||
},
|
||||
|
||||
async copyDiagnosticsReport() {
|
||||
const report = await this.buildDiagnosticsReport();
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(report);
|
||||
return { ok: true, report } as const;
|
||||
}
|
||||
return { ok: false, report } as const;
|
||||
const result = await this.copyTextToClipboard(report);
|
||||
return { ...result, report } as const;
|
||||
},
|
||||
|
||||
checkLastMessage() {
|
||||
|
||||
Reference in New Issue
Block a user