/** * FileLink — clickable file path link for markdown content. * Opens the file in the built-in editor (team context) or copies the absolute path (session context). * * Follows the LocalImage pattern (MarkdownViewer.tsx) — a standalone React component * used inside react-markdown's `a` component factory. */ import React from 'react'; import { PROSE_LINK } from '@renderer/constants/cssVariables'; import { useStore } from '@renderer/store'; import type { AppState } from '@renderer/store/types'; import { Check, FileCode } from 'lucide-react'; // ============================================================================= // Exported utilities // ============================================================================= /** Parse "path:line" format (e.g. "src/foo.ts:42") */ export function parsePathWithLine(href: string): { filePath: string; line: number | null } { let decoded: string; try { decoded = decodeURIComponent(href); } catch { decoded = href; } const match = decoded.match(/^(.+?):(\d+)$/); if (match) return { filePath: match[1], line: parseInt(match[2], 10) }; return { filePath: decoded, line: null }; } /** Check if a URL is relative (not a protocol, not a hash, not data/mailto) */ export function isRelativeUrl(url: string): boolean { return ( !!url && !url.startsWith('#') && !url.includes('://') && !url.startsWith('data:') && !url.startsWith('mailto:') ); } // ============================================================================= // Internal helpers // ============================================================================= function resolveRelativePath(relativeSrc: string, baseDir: string): string { const parts = `${baseDir}/${relativeSrc}`.split('/'); const resolved: string[] = []; for (const part of parts) { if (part === '.' || part === '') continue; if (part === '..') { resolved.pop(); } else { resolved.push(part); } } return '/' + resolved.join('/'); } /** Project path based on active tab context (avoids stale cross-tab state) */ function selectContextProjectPath(s: AppState): string | null { const activeTab = s.openTabs.find((t) => t.id === s.activeTabId); if (!activeTab) return null; switch (activeTab.type) { case 'team': return s.selectedTeamData?.config.projectPath ?? null; case 'session': return s.sessionDetail?.session?.projectPath ?? null; default: return null; } } function selectIsTeamTab(s: AppState): boolean { const activeTab = s.openTabs.find((t) => t.id === s.activeTabId); return activeTab?.type === 'team'; } // ============================================================================= // Component // ============================================================================= interface FileLinkProps { href: string; children: React.ReactNode; } export const FileLink = React.memo(function FileLink({ href, children, }: FileLinkProps): React.ReactElement { const projectPath = useStore(selectContextProjectPath); const isTeamTab = useStore(selectIsTeamTab); const [copied, setCopied] = React.useState(false); if (!projectPath) { return ( {children} ); } const { filePath: relativePath, line } = parsePathWithLine(href); const absolutePath = resolveRelativePath(relativePath, projectPath); const handleClick = (e: React.MouseEvent) => { e.preventDefault(); if (isTeamTab) { const { revealFileInEditor, setPendingGoToLine } = useStore.getState(); if (line !== null) setPendingGoToLine(line); revealFileInEditor(absolutePath); } else { void navigator.clipboard.writeText(absolutePath).then( () => { setCopied(true); setTimeout(() => setCopied(false), 1500); }, () => { // Clipboard API may not be available in all contexts } ); } }; return ( {children} {copied && } ); });