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