mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
feat: Universal clipboard utility with improved copy functionality (#663)
* feat: Add universal clipboard utility with enhanced copy functionality - Add comprehensive clipboard utility (src/utils/clipboard.ts) with: - Modern Clipboard API with automatic fallback to document.execCommand - Cross-browser compatibility and security context handling - Detailed error reporting and debugging capabilities - Support for secure (HTTPS) and insecure (HTTP/localhost) contexts - Update components to use new clipboard utility: - BugReportModal: Enhanced copy functionality with error handling - CodeViewerModal: Improved copy-to-clipboard for code snippets - IDEGlobalRules: Robust clipboard operations for rule copying - McpConfigSection: Enhanced config and command copying - DocumentCard: Reliable ID copying functionality - KnowledgeInspector: Improved content copying - ButtonPlayground: Enhanced CSS style copying - Benefits: - Consistent copy behavior across all browser environments - Better error handling and user feedback - Improved accessibility and security context support - Enhanced debugging capabilities Fixes #662 * fix: Improve clipboard utility robustness and add missing host configuration Clipboard utility improvements: - Prevent textarea element leak in clipboard fallback with proper cleanup - Add SSR compatibility with typeof guards for navigator/document - Use finally block to ensure cleanup in all error cases Host configuration fixes: - Update MCP API to use ARCHON_HOST environment variable instead of hardcoded localhost - Add ARCHON_HOST to docker-compose environment variables - Ensures MCP configuration shows correct hostname in different deployment environments Addresses CodeRabbit feedback and restores missing host functionality * fix: Use relative URLs for Vite proxy in development - Update getApiUrl() to return empty string when VITE_API_URL is unset - Ensures all API requests use relative paths (/api/...) in development - Prevents bypassing Vite proxy with absolute URLs (host:port) - Maintains existing functionality for explicit VITE_API_URL configuration - Fix TypeScript error by using bracket notation for environment access Addresses CodeRabbit feedback about dev setup relying on Vite proxy * fix: Resolve TypeScript error in API configuration Use proper type assertion to access VITE_API_URL environment variable * Address PR review comments: Move clipboard utility to features architecture - Move clipboard.ts from src/utils/ to src/features/shared/utils/ - Remove copyTextToClipboard backward compatibility function (dead code) - Update all import statements to use new file location - Maintain full clipboard functionality with modern API and fallbacks Addresses: - Review comment r2348420743: Move to new architecture location - Review comment r2348422625: Remove unused backward compatibility function 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix SSR safety issue in clipboard utility - Add typeof navigator !== 'undefined' guard before accessing navigator.clipboard - Add typeof document !== 'undefined' guard before using document.execCommand fallback - Ensure proper error handling when running in server-side environment - Maintain existing functionality while preventing ReferenceError during SSR/prerender Addresses CodeRabbit feedback: Navigator access needs SSR-safe guards 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
89fa9b4b49
commit
9ffca825ff
@@ -11,6 +11,7 @@ import {
|
||||
BugContext,
|
||||
BugReportData,
|
||||
} from "../../services/bugReportService";
|
||||
import { copyToClipboard } from "../../features/shared/utils/clipboard";
|
||||
|
||||
interface BugReportModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -99,13 +100,21 @@ export const BugReportModal: React.FC<BugReportModalProps> = ({
|
||||
// Fallback: copy to clipboard
|
||||
const formattedReport =
|
||||
bugReportService.formatReportForClipboard(bugReportData);
|
||||
await navigator.clipboard.writeText(formattedReport);
|
||||
const clipboardResult = await copyToClipboard(formattedReport);
|
||||
|
||||
showToast(
|
||||
"Failed to create GitHub issue, but bug report was copied to clipboard. Please paste it in a new GitHub issue.",
|
||||
"warning",
|
||||
10000,
|
||||
);
|
||||
if (clipboardResult.success) {
|
||||
showToast(
|
||||
"Failed to create GitHub issue, but bug report was copied to clipboard. Please paste it in a new GitHub issue.",
|
||||
"warning",
|
||||
10000,
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
"Failed to create GitHub issue and could not copy to clipboard. Please report manually.",
|
||||
"error",
|
||||
10000,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Bug report submission failed:", error);
|
||||
@@ -118,15 +127,15 @@ export const BugReportModal: React.FC<BugReportModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
const handleCopyToClipboard = async () => {
|
||||
const bugReportData: BugReportData = { ...report, context };
|
||||
const formattedReport =
|
||||
bugReportService.formatReportForClipboard(bugReportData);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(formattedReport);
|
||||
const result = await copyToClipboard(formattedReport);
|
||||
if (result.success) {
|
||||
showToast("Bug report copied to clipboard", "success");
|
||||
} catch {
|
||||
} else {
|
||||
showToast("Failed to copy to clipboard", "error");
|
||||
}
|
||||
};
|
||||
@@ -372,7 +381,7 @@ export const BugReportModal: React.FC<BugReportModalProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={copyToClipboard}
|
||||
onClick={handleCopyToClipboard}
|
||||
className="sm:order-1"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
|
||||
@@ -30,6 +30,7 @@ import 'prismjs/components/prism-graphql'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import { Button } from '../ui/Button'
|
||||
import { Badge } from '../ui/Badge'
|
||||
import { copyToClipboard } from '../../features/shared/utils/clipboard'
|
||||
|
||||
export interface CodeExample {
|
||||
id: string
|
||||
@@ -102,11 +103,15 @@ export const CodeViewerModal: React.FC<CodeViewerModalProps> = ({
|
||||
setActiveExampleIndex(0)
|
||||
}, [searchQuery])
|
||||
|
||||
const handleCopyCode = () => {
|
||||
const handleCopyCode = async () => {
|
||||
if (activeExample) {
|
||||
navigator.clipboard.writeText(activeExample.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
const result = await copyToClipboard(activeExample.code)
|
||||
if (result.success) {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} else {
|
||||
console.error('Failed to copy to clipboard:', result.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Copy, Check, Link, Unlink } from 'lucide-react';
|
||||
import { NeonButton, type CornerRadius, type GlowIntensity, type ColorOption } from '../ui/NeonButton';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { copyToClipboard } from '../../features/shared/utils/clipboard';
|
||||
|
||||
export const ButtonPlayground: React.FC = () => {
|
||||
const [showLayer2, setShowLayer2] = useState(true);
|
||||
@@ -279,10 +280,14 @@ export const ButtonPlayground: React.FC = () => {
|
||||
return colors[color];
|
||||
};
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(generateCSS());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
const handleCopyToClipboard = async () => {
|
||||
const result = await copyToClipboard(generateCSS());
|
||||
if (result.success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
console.error('Failed to copy to clipboard:', result.error);
|
||||
}
|
||||
};
|
||||
|
||||
// Corner input component
|
||||
@@ -654,7 +659,7 @@ export const ButtonPlayground: React.FC = () => {
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">CSS Styles</h3>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
onClick={handleCopyToClipboard}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors flex items-center gap-2 shadow-lg shadow-purple-600/25"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FileCode, Copy, Check } from 'lucide-react';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { useToast } from '../../features/ui/hooks/useToast';
|
||||
import { copyToClipboard } from '../../features/shared/utils/clipboard';
|
||||
|
||||
type RuleType = 'claude' | 'universal';
|
||||
|
||||
@@ -472,8 +473,9 @@ archon:manage_task(
|
||||
};
|
||||
|
||||
const handleCopyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(currentRules);
|
||||
const result = await copyToClipboard(currentRules);
|
||||
|
||||
if (result.success) {
|
||||
setCopied(true);
|
||||
showToast(`${selectedRuleType === 'claude' ? 'Claude Code' : 'Universal'} rules copied to clipboard!`, 'success');
|
||||
|
||||
@@ -481,8 +483,8 @@ archon:manage_task(
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
} else {
|
||||
console.error('Failed to copy text:', result.error);
|
||||
showToast('Failed to copy to clipboard', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,29 +5,17 @@
|
||||
* and handles different environments (development, Docker, production)
|
||||
*/
|
||||
|
||||
// Get the API URL from environment or construct it
|
||||
// Get the API URL from environment or use relative URLs for proxy
|
||||
export function getApiUrl(): string {
|
||||
// For relative URLs in production (goes through proxy)
|
||||
if (import.meta.env.PROD) {
|
||||
return '';
|
||||
// Check if VITE_API_URL is explicitly provided (for absolute URL mode)
|
||||
const viteApiUrl = (import.meta.env as any).VITE_API_URL as string | undefined;
|
||||
if (viteApiUrl) {
|
||||
return viteApiUrl;
|
||||
}
|
||||
|
||||
// Check if VITE_API_URL is provided (set by docker-compose)
|
||||
if (import.meta.env.VITE_API_URL) {
|
||||
return import.meta.env.VITE_API_URL;
|
||||
}
|
||||
|
||||
// For development, construct from window location
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.hostname;
|
||||
// Use configured port or default to 8181
|
||||
const port = import.meta.env.VITE_ARCHON_SERVER_PORT || '8181';
|
||||
|
||||
if (!import.meta.env.VITE_ARCHON_SERVER_PORT) {
|
||||
console.info('[Archon] Using default ARCHON_SERVER_PORT: 8181');
|
||||
}
|
||||
|
||||
return `${protocol}//${host}:${port}`;
|
||||
// Default to relative URLs to use Vite proxy in development
|
||||
// or direct proxy in production - this ensures all requests go through proxy
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get the base path for API endpoints
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useInspectorPagination } from "../hooks/useInspectorPagination";
|
||||
import { ContentViewer } from "./ContentViewer";
|
||||
import { InspectorHeader } from "./InspectorHeader";
|
||||
import { InspectorSidebar } from "./InspectorSidebar";
|
||||
import { copyToClipboard } from "../../../shared/utils/clipboard";
|
||||
|
||||
interface KnowledgeInspectorProps {
|
||||
item: KnowledgeItem;
|
||||
@@ -92,12 +93,12 @@ export const KnowledgeInspector: React.FC<KnowledgeInspectorProps> = ({
|
||||
}, [viewMode, currentItems, selectedItem]);
|
||||
|
||||
const handleCopy = useCallback(async (text: string, id: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const result = await copyToClipboard(text);
|
||||
if (result.success) {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId((v) => (v === id ? null : v)), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
} else {
|
||||
console.error("Failed to copy to clipboard:", result.error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useToast } from "../../ui/hooks";
|
||||
import { Button, cn, glassmorphism, Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives";
|
||||
import type { McpServerConfig, McpServerStatus, SupportedIDE } from "../types";
|
||||
import { copyToClipboard } from "../../shared/utils/clipboard";
|
||||
|
||||
interface McpConfigSectionProps {
|
||||
config?: McpServerConfig;
|
||||
@@ -185,10 +186,16 @@ export const McpConfigSection: React.FC<McpConfigSectionProps> = ({ config, stat
|
||||
);
|
||||
}
|
||||
|
||||
const handleCopyConfig = () => {
|
||||
const handleCopyConfig = async () => {
|
||||
const configText = ideConfigurations[selectedIDE].configGenerator(config);
|
||||
navigator.clipboard.writeText(configText);
|
||||
showToast("Configuration copied to clipboard", "success");
|
||||
const result = await copyToClipboard(configText);
|
||||
|
||||
if (result.success) {
|
||||
showToast("Configuration copied to clipboard", "success");
|
||||
} else {
|
||||
console.error("Failed to copy config:", result.error);
|
||||
showToast("Failed to copy configuration", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCursorOneClick = () => {
|
||||
@@ -202,10 +209,16 @@ export const McpConfigSection: React.FC<McpConfigSectionProps> = ({ config, stat
|
||||
showToast("Opening Cursor with Archon MCP configuration...", "info");
|
||||
};
|
||||
|
||||
const handleClaudeCodeCommand = () => {
|
||||
const handleClaudeCodeCommand = async () => {
|
||||
const command = `claude mcp add --transport http archon http://${config.host}:${config.port}/mcp`;
|
||||
navigator.clipboard.writeText(command);
|
||||
showToast("Command copied to clipboard", "success");
|
||||
const result = await copyToClipboard(command);
|
||||
|
||||
if (result.success) {
|
||||
showToast("Command copied to clipboard", "success");
|
||||
} else {
|
||||
console.error("Failed to copy command:", result.error);
|
||||
showToast("Failed to copy command", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const selectedConfig = ideConfigurations[selectedIDE];
|
||||
|
||||
@@ -15,6 +15,7 @@ import type React from "react";
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { Button } from "../../../ui/primitives";
|
||||
import type { DocumentCardProps, DocumentType } from "../types";
|
||||
import { copyToClipboard } from "../../../shared/utils/clipboard";
|
||||
|
||||
const getDocumentIcon = (type?: DocumentType) => {
|
||||
switch (type) {
|
||||
@@ -67,11 +68,13 @@ export const DocumentCard = memo(({ document, isActive, onSelect, onDelete }: Do
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopyId = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(document.id);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
const result = await copyToClipboard(document.id);
|
||||
if (result.success) {
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
}
|
||||
},
|
||||
[document.id],
|
||||
);
|
||||
|
||||
139
archon-ui-main/src/features/shared/utils/clipboard.ts
Normal file
139
archon-ui-main/src/features/shared/utils/clipboard.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Universal clipboard utility with modern API and fallback support
|
||||
* Handles various security contexts and browser compatibility issues
|
||||
*/
|
||||
|
||||
export interface ClipboardResult {
|
||||
success: boolean;
|
||||
method: 'clipboard-api' | 'execCommand' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with automatic fallback mechanisms
|
||||
* @param text - Text to copy to clipboard
|
||||
* @returns Promise<ClipboardResult> - Result of the copy operation
|
||||
*/
|
||||
export const copyToClipboard = async (text: string): Promise<ClipboardResult> => {
|
||||
// Try modern clipboard API first with SSR-safe guards
|
||||
if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.clipboard &&
|
||||
navigator.clipboard.writeText
|
||||
) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return { success: true, method: 'clipboard-api' };
|
||||
} catch (error) {
|
||||
console.warn('Clipboard API failed, trying fallback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to document.execCommand for older browsers or insecure contexts
|
||||
// Add SSR guards for document access
|
||||
if (typeof document === 'undefined') {
|
||||
return {
|
||||
success: false,
|
||||
method: 'failed',
|
||||
error: 'Running in server-side environment - clipboard not available'
|
||||
};
|
||||
}
|
||||
|
||||
let textarea: HTMLTextAreaElement | null = null;
|
||||
try {
|
||||
// Ensure document.body exists before proceeding
|
||||
if (!document.body) {
|
||||
return {
|
||||
success: false,
|
||||
method: 'failed',
|
||||
error: 'document.body is not available'
|
||||
};
|
||||
}
|
||||
|
||||
textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-9999px';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.setAttribute('aria-hidden', 'true');
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
|
||||
const success = document.execCommand('copy');
|
||||
|
||||
if (success) {
|
||||
return { success: true, method: 'execCommand' };
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
method: 'failed',
|
||||
error: 'execCommand copy returned false'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
method: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
} finally {
|
||||
// Always clean up the textarea element if it was created and added to DOM
|
||||
if (textarea && document.body && document.body.contains(textarea)) {
|
||||
try {
|
||||
document.body.removeChild(textarea);
|
||||
} catch (cleanupError) {
|
||||
// Ignore cleanup errors - element may have already been removed
|
||||
console.warn('Failed to cleanup textarea element:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if clipboard functionality is supported in current context
|
||||
* @returns boolean - True if any clipboard method is available
|
||||
*/
|
||||
export const isClipboardSupported = (): boolean => {
|
||||
// Check modern clipboard API with proper SSR guards
|
||||
if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof navigator.clipboard !== 'undefined' &&
|
||||
typeof navigator.clipboard.writeText === 'function'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check execCommand fallback with SSR guards
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
typeof document.queryCommandSupported === 'function'
|
||||
) {
|
||||
try {
|
||||
return document.queryCommandSupported('copy');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Return false if running in SSR or globals are unavailable
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current security context information for debugging
|
||||
* @returns string - Description of current security context
|
||||
*/
|
||||
export const getSecurityContext = (): string => {
|
||||
if (typeof window === 'undefined') return 'server';
|
||||
if (window.isSecureContext) return 'secure';
|
||||
if (window.location.protocol === 'https:') return 'https';
|
||||
if (window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1') return 'localhost';
|
||||
return 'insecure';
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ services:
|
||||
- ARCHON_MCP_PORT=${ARCHON_MCP_PORT:-8051}
|
||||
- ARCHON_AGENTS_PORT=${ARCHON_AGENTS_PORT:-8052}
|
||||
- AGENTS_ENABLED=${AGENTS_ENABLED:-false}
|
||||
- ARCHON_HOST=${HOST:-localhost}
|
||||
networks:
|
||||
- app-network
|
||||
volumes:
|
||||
|
||||
@@ -109,7 +109,7 @@ async def get_mcp_config():
|
||||
|
||||
# Configuration for streamable-http mode with actual port
|
||||
config = {
|
||||
"host": "localhost",
|
||||
"host": os.getenv("ARCHON_HOST", "localhost"),
|
||||
"port": mcp_port,
|
||||
"transport": "streamable-http",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user